timelog 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +18 -0
- data/Gemfile +4 -0
- data/LICENSE +22 -0
- data/README.md +29 -0
- data/Rakefile +2 -0
- data/bin/timelog +22 -0
- data/lib/timelog.rb +41 -0
- data/lib/timelog/book.rb +91 -0
- data/lib/timelog/book/clients.rb +29 -0
- data/lib/timelog/book/entries.rb +151 -0
- data/lib/timelog/book/projects.rb +33 -0
- data/lib/timelog/command.rb +59 -0
- data/lib/timelog/command/active.rb +20 -0
- data/lib/timelog/command/archive.rb +22 -0
- data/lib/timelog/command/client_add.rb +17 -0
- data/lib/timelog/command/help.rb +6 -0
- data/lib/timelog/command/list.rb +64 -0
- data/lib/timelog/command/pause.rb +15 -0
- data/lib/timelog/command/project_add.rb +18 -0
- data/lib/timelog/command/resume.rb +15 -0
- data/lib/timelog/command/start.rb +27 -0
- data/lib/timelog/command/stop.rb +19 -0
- data/lib/timelog/configuration.rb +36 -0
- data/lib/timelog/database.rb +55 -0
- data/lib/timelog/entry.rb +100 -0
- data/lib/timelog/version.rb +3 -0
- data/timelog.gemspec +21 -0
- metadata +138 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2013 Sune Kibsgaard Pedersen
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# Timelog
|
2
|
+
|
3
|
+
TODO: Write a gem description
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
gem 'timelog'
|
10
|
+
|
11
|
+
And then execute:
|
12
|
+
|
13
|
+
$ bundle
|
14
|
+
|
15
|
+
Or install it yourself as:
|
16
|
+
|
17
|
+
$ gem install timelog
|
18
|
+
|
19
|
+
## Usage
|
20
|
+
|
21
|
+
TODO: Write usage instructions here
|
22
|
+
|
23
|
+
## Contributing
|
24
|
+
|
25
|
+
1. Fork it
|
26
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
27
|
+
3. Commit your changes (`git commit -am 'Added some feature'`)
|
28
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
29
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
data/bin/timelog
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
lib = File.expand_path(File.dirname(__FILE__) + '/../lib')
|
3
|
+
$LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib)
|
4
|
+
|
5
|
+
require 'timelog'
|
6
|
+
|
7
|
+
debug = ENV['TIMELOG_DEBUG'] || ENV['timelog_debug']
|
8
|
+
config = ENV['TIMELOG_CONFIG'] || ENV['timelog_config'] || "#{Dir.home}/.timelog.rb"
|
9
|
+
|
10
|
+
begin
|
11
|
+
Timelog::Book.open(config) do
|
12
|
+
puts execute ARGV
|
13
|
+
end
|
14
|
+
rescue Timelog::Error => e
|
15
|
+
abort e.message
|
16
|
+
rescue e
|
17
|
+
if debug
|
18
|
+
raise e
|
19
|
+
else
|
20
|
+
abort "an unexpected error occurred"
|
21
|
+
end
|
22
|
+
end
|
data/lib/timelog.rb
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'active_support/all'
|
2
|
+
require 'active_model'
|
3
|
+
require 'timelog/version'
|
4
|
+
require 'timelog/database'
|
5
|
+
require 'timelog/book'
|
6
|
+
require 'timelog/configuration'
|
7
|
+
require 'timelog/entry'
|
8
|
+
require 'timelog/command'
|
9
|
+
|
10
|
+
module Timelog
|
11
|
+
class Error < StandardError
|
12
|
+
def initialize(msg = "unknown timelog error occurred")
|
13
|
+
super(msg)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.create_error(name, default_message)
|
18
|
+
c = Class.new(Error) do |msg|
|
19
|
+
define_method :initialize do |msg = default_message|
|
20
|
+
super(msg)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
Kernel.const_set name.camelcase, c
|
24
|
+
end
|
25
|
+
|
26
|
+
create_error 'invalid_argument_error', 'invalid arguments'
|
27
|
+
create_error 'invalid_command_error', 'invalid command'
|
28
|
+
create_error 'invalid_entry', 'invalid entry'
|
29
|
+
create_error 'invalid_query_error', 'invalid query'
|
30
|
+
create_error 'entry_started_error', 'entry already started'
|
31
|
+
create_error 'entry_paused_error', 'entry already paused'
|
32
|
+
create_error 'entry_not_active_error', 'entry not active'
|
33
|
+
create_error 'entry_not_paused_error', 'entry is not paused'
|
34
|
+
create_error 'entry_not_started_error', 'entry is not started'
|
35
|
+
create_error 'client_exists_error', 'client already exists'
|
36
|
+
create_error 'client_not_found_error', 'client was not found'
|
37
|
+
create_error 'missing_client_name_error', 'missing client name'
|
38
|
+
create_error 'project_exists_error', 'project already exists'
|
39
|
+
create_error 'project_not_found_error', 'project was not found'
|
40
|
+
create_error 'missing_project_name_error', 'missing project name'
|
41
|
+
end
|
data/lib/timelog/book.rb
ADDED
@@ -0,0 +1,91 @@
|
|
1
|
+
require 'timelog/book/entries'
|
2
|
+
require 'timelog/book/clients'
|
3
|
+
require 'timelog/book/projects'
|
4
|
+
|
5
|
+
module Timelog
|
6
|
+
class Book
|
7
|
+
attr_reader :configuration, :database
|
8
|
+
|
9
|
+
# initialize a book
|
10
|
+
#
|
11
|
+
# using a configuration object.
|
12
|
+
# have a look at the .open method for
|
13
|
+
# initializing a book from a configuration file
|
14
|
+
def initialize(configuration)
|
15
|
+
@configuration = configuration
|
16
|
+
@database = Database.new(@configuration)
|
17
|
+
@entries = Entries.new(self)
|
18
|
+
@clients = Clients.new(self)
|
19
|
+
@projects = Projects.new(self)
|
20
|
+
end
|
21
|
+
|
22
|
+
# execute a command
|
23
|
+
#
|
24
|
+
# this finds and executes a command given from a string
|
25
|
+
# or an array of arguments
|
26
|
+
def execute(arguments)
|
27
|
+
arguments = arguments.split if arguments.is_a? String
|
28
|
+
# we have arguments, so try to find the command
|
29
|
+
if arguments.size > 0
|
30
|
+
name = ""
|
31
|
+
# add one argument at a time, until (or never) a command matches
|
32
|
+
command = while(argument = arguments.shift)
|
33
|
+
name += " #{argument}"
|
34
|
+
if obj = Command.get(name.strip, self, arguments)
|
35
|
+
break obj
|
36
|
+
end
|
37
|
+
end
|
38
|
+
# no arguments, use generic command
|
39
|
+
else
|
40
|
+
command = Command.new(self)
|
41
|
+
end
|
42
|
+
raise InvalidCommandError unless command
|
43
|
+
|
44
|
+
# looking for command help? no? then execute!
|
45
|
+
arguments.first.try(:downcase) == "help" ? command.help : command.execute
|
46
|
+
end
|
47
|
+
|
48
|
+
# trigger an event
|
49
|
+
def trigger(group, event, *args)
|
50
|
+
case group
|
51
|
+
when :before
|
52
|
+
@configuration.callbacks[group][event].each {|block| instance_exec(*args, &block)} if @configuration.callbacks[group][event]
|
53
|
+
when :after
|
54
|
+
@configuration.callbacks[group][event].each {|block| instance_exec(*args, &block)} if @configuration.callbacks[group][event]
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# get the entries controller
|
59
|
+
#
|
60
|
+
# use this for manipulate entries in the book
|
61
|
+
# if a block is given, it will be evalulated in the
|
62
|
+
# controllers context
|
63
|
+
def entries(&block)
|
64
|
+
@entries.instance_eval(&block) if block_given?
|
65
|
+
@entries
|
66
|
+
end
|
67
|
+
|
68
|
+
def clients(&block)
|
69
|
+
@clients.instance_eval(&block) if block_given?
|
70
|
+
@clients
|
71
|
+
end
|
72
|
+
|
73
|
+
def projects(&block)
|
74
|
+
@projects.instance_eval(&block) if block_given?
|
75
|
+
@projects
|
76
|
+
end
|
77
|
+
|
78
|
+
class << self
|
79
|
+
# open an existing book
|
80
|
+
#
|
81
|
+
# using a configuration file and an
|
82
|
+
# optional block which will be evaluated
|
83
|
+
# in the book instance
|
84
|
+
def open(file, &block)
|
85
|
+
book = Book.new(Configuration.open(file))
|
86
|
+
book.instance_eval(&block) if block_given?
|
87
|
+
book
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Timelog
|
2
|
+
class Book
|
3
|
+
class Clients
|
4
|
+
def initialize(book)
|
5
|
+
@book = book
|
6
|
+
end
|
7
|
+
|
8
|
+
def add(name, shortcut = nil)
|
9
|
+
raise ClientExistsError if exists? name or (shortcut and exists? shortcut)
|
10
|
+
@book.database.execute("insert into clients (name, shortcut) values(?, ?)", [name, shortcut])
|
11
|
+
@book.database.first_value("select id from clients order by id desc limit 1")
|
12
|
+
end
|
13
|
+
|
14
|
+
def exists?(client)
|
15
|
+
@book.database.first_value("select id from clients where id = ? or name = ? or shortcut = ?", [client, client, client])
|
16
|
+
end
|
17
|
+
|
18
|
+
def name(client)
|
19
|
+
raise ClientNotFoundError unless exists? client
|
20
|
+
@book.database.first_value("select name from clients where id = ? or name = ? or shortcut = ?", [client, client, client])
|
21
|
+
end
|
22
|
+
|
23
|
+
def id(client)
|
24
|
+
raise ClientNotFoundError unless exists? client
|
25
|
+
@book.database.first_value("select id from clients where id = ? or name = ? or shortcut = ?", [client, client, client])
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,151 @@
|
|
1
|
+
module Timelog
|
2
|
+
class Book
|
3
|
+
class Entries
|
4
|
+
QUERY_REGEX = /\A\d+|all|archived|non\-archived|active|today\Z/
|
5
|
+
|
6
|
+
def initialize(book)
|
7
|
+
@book = book
|
8
|
+
end
|
9
|
+
|
10
|
+
# pauses an entry
|
11
|
+
def pause(entry, options = {})
|
12
|
+
raise EntryNotActiveError unless entry.active?
|
13
|
+
raise EntryPausedError if entry.paused?
|
14
|
+
raise EntryNotStarted unless entry.started?
|
15
|
+
@book.trigger :before, :pause, entry
|
16
|
+
entry.pause options[:at]
|
17
|
+
write entry
|
18
|
+
@book.trigger :after, :pause, entry
|
19
|
+
end
|
20
|
+
|
21
|
+
# resumes an entry
|
22
|
+
def resume(entry, options = {})
|
23
|
+
raise EntryNotActiveError unless entry.active?
|
24
|
+
raise EntryNotPausedError unless entry.paused?
|
25
|
+
@book.trigger :before, :resume, entry
|
26
|
+
entry.resume options[:at]
|
27
|
+
write entry
|
28
|
+
@book.trigger :after, :resume, entry
|
29
|
+
end
|
30
|
+
|
31
|
+
# starts an entry
|
32
|
+
def start(entry, options = {})
|
33
|
+
raise EntryStartedError if entry.started?
|
34
|
+
@book.trigger :before, :start, entry
|
35
|
+
entry.started = options[:at] || Time.now
|
36
|
+
write entry
|
37
|
+
@book.trigger :after, :start, entry
|
38
|
+
end
|
39
|
+
|
40
|
+
# ends the entry
|
41
|
+
def stop(entry, options = {})
|
42
|
+
raise EntryNotStartedError unless entry.started?
|
43
|
+
@book.trigger :before, :stop, entry
|
44
|
+
entry.resume(options[:at] || Time.now) if entry.paused?
|
45
|
+
entry.stopped = options[:at] || Time.now
|
46
|
+
write entry
|
47
|
+
@book.trigger :after, :stop, entry
|
48
|
+
end
|
49
|
+
|
50
|
+
# archives the entry
|
51
|
+
def archive(entry, options = {})
|
52
|
+
@book.trigger :before, :archive, entry
|
53
|
+
entry.archived = true
|
54
|
+
write entry
|
55
|
+
@book.trigger :after, :archive, entry
|
56
|
+
end
|
57
|
+
|
58
|
+
# get the active or a new entry
|
59
|
+
#
|
60
|
+
# this can be an already started entry
|
61
|
+
# or a fresh nonstarted entry if an ongoing
|
62
|
+
# entry was not found
|
63
|
+
def active_or_new
|
64
|
+
active || Entry.new
|
65
|
+
end
|
66
|
+
|
67
|
+
# get the active entry
|
68
|
+
#
|
69
|
+
# returns the currently active entry
|
70
|
+
# or nil if none was found
|
71
|
+
def active
|
72
|
+
where("active").first
|
73
|
+
end
|
74
|
+
|
75
|
+
# find entries from query
|
76
|
+
#
|
77
|
+
# get a list of entries, based on the query provided
|
78
|
+
def where(query)
|
79
|
+
raise InvalidQueryError if query !~ QUERY_REGEX
|
80
|
+
bind = []
|
81
|
+
where = case query.to_s
|
82
|
+
when /\A\d+\Z/
|
83
|
+
bind << query
|
84
|
+
"where id = ?"
|
85
|
+
when "all" then ""
|
86
|
+
when "active" then "where stopped is null"
|
87
|
+
when "archived" then "where archived = 1"
|
88
|
+
when "non-archived" then "where archived = 0"
|
89
|
+
when "today"
|
90
|
+
bind += [Date.today.to_time.to_i, Date.today.to_time.to_i + 24 * 60 * 60]
|
91
|
+
"where started > ? and started < ?"
|
92
|
+
end
|
93
|
+
read(@book.database.first_column("select id from entries #{where} order by id", *bind))
|
94
|
+
end
|
95
|
+
|
96
|
+
# get entries from ids
|
97
|
+
#
|
98
|
+
# fetches and loads all entries with the given ids
|
99
|
+
# returns an array with entries sorted by id desc
|
100
|
+
def read(id)
|
101
|
+
id = [id] unless id.is_a? Array
|
102
|
+
@book.database.execute("select * from entries where id in (#{id.compact.map {"?"}.join(", ")}) order by id", id.compact).map do |row|
|
103
|
+
entry = Entry.new
|
104
|
+
entry.id = row[0]
|
105
|
+
entry.client = row[1]
|
106
|
+
entry.project = row[2]
|
107
|
+
entry.description = row[3]
|
108
|
+
entry.started = Time.at(row[4]) if row[4]
|
109
|
+
entry.stopped = Time.at(row[5]) if row[5]
|
110
|
+
if row[6] and pauses = Marshal.load(row[6])
|
111
|
+
pauses.each {|pause, resume| entry.pause(pause); entry.resume(resume)}
|
112
|
+
end
|
113
|
+
entry.archived = (row[7] == 1) ? true : false
|
114
|
+
entry
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
# write a single entry to book
|
119
|
+
#
|
120
|
+
# persists an entry to the database
|
121
|
+
# automatically sets the id on the entry
|
122
|
+
# if it is a new entry
|
123
|
+
def write(entry)
|
124
|
+
if entry.valid?
|
125
|
+
data = [
|
126
|
+
entry.client,
|
127
|
+
entry.project,
|
128
|
+
entry.description,
|
129
|
+
entry.started.try(:to_i),
|
130
|
+
entry.stopped.try(:to_i),
|
131
|
+
Marshal.dump(entry.pauses),
|
132
|
+
entry.archived ? 1 : 0
|
133
|
+
]
|
134
|
+
if entry.id
|
135
|
+
@book.database.execute("update entries set
|
136
|
+
client = ?, project = ?, description = ?,
|
137
|
+
started = ?, stopped = ?, pauses = ?,
|
138
|
+
archived = ? where id = ?", data << entry.id)
|
139
|
+
else
|
140
|
+
@book.database.execute("insert into entries
|
141
|
+
(client, project, description, started, stopped, pauses, archived)
|
142
|
+
values(?, ?, ?, ?, ?, ?, ?)", data)
|
143
|
+
entry.id = @book.database.first_value("select id from entries order by id desc limit 1")
|
144
|
+
end
|
145
|
+
else
|
146
|
+
raise InvalidEntryError
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module Timelog
|
2
|
+
class Book
|
3
|
+
class Projects
|
4
|
+
def initialize(book)
|
5
|
+
@book = book
|
6
|
+
end
|
7
|
+
|
8
|
+
def add(client, project, shortcut = nil)
|
9
|
+
raise ProjectExistsError if exists?(client, project) or (shortcut and exists?(client, shortcut))
|
10
|
+
client = @book.clients.exists? client
|
11
|
+
@book.database.execute("insert into projects (client, name, shortcut) values(?, ?, ?)", [client, project, shortcut])
|
12
|
+
@book.database.first_value("select id from projects order by id desc limit 1")
|
13
|
+
end
|
14
|
+
|
15
|
+
def exists?(client, project)
|
16
|
+
raise ClientNotFoundError unless client = @book.clients.exists?(client)
|
17
|
+
@book.database.first_value("select id from projects where client = ? and (id = ? or name = ? or shortcut = ?)", [client, project, project, project])
|
18
|
+
end
|
19
|
+
|
20
|
+
def name(client, project)
|
21
|
+
client = @book.clients.id(client)
|
22
|
+
raise ProjectNotFoundError unless exists? client, project
|
23
|
+
@book.database.first_value("select name from projects where client = ? and (id = ? or name = ? or shortcut = ?)", [client, project, project, project])
|
24
|
+
end
|
25
|
+
|
26
|
+
def id(client, project)
|
27
|
+
client = @book.clients.id(client)
|
28
|
+
raise ProjectNotFoundError unless exists? client, project
|
29
|
+
@book.database.first_value("select id from projects where client = ? and (id = ? or name = ? or shortcut = ?)", [client, project, project, project])
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
module Timelog
|
2
|
+
class Command
|
3
|
+
attr_reader :book, :session, :arguments
|
4
|
+
@commands = {}
|
5
|
+
|
6
|
+
def initialize(book, arguments = [])
|
7
|
+
@book = book
|
8
|
+
@session = session
|
9
|
+
@arguments = arguments
|
10
|
+
end
|
11
|
+
|
12
|
+
def execute
|
13
|
+
help
|
14
|
+
end
|
15
|
+
|
16
|
+
def help
|
17
|
+
<<-HELP
|
18
|
+
usage: timelog <command> [arguments]
|
19
|
+
|
20
|
+
Commands:
|
21
|
+
#{Command.known.join("\n ")}
|
22
|
+
|
23
|
+
See 'timelog <command> help' for more information on a specific command.
|
24
|
+
HELP
|
25
|
+
end
|
26
|
+
|
27
|
+
def time_in_arguments(default = Time.now)
|
28
|
+
if time = @arguments.find {|arg| arg.match /\A([01]?[0-9]|2[0-3])([\:\.]([0-5][0-9])|)\Z/}
|
29
|
+
hour = time.match(/^([01]?[0-9]|2[0-3])/)[0].to_i
|
30
|
+
minute = time.match(/([\:\.]([0-5][0-9]))$/).try(:values_at, 2).try(:first).try(:to_i) || 0
|
31
|
+
@arguments.delete_if {|arg| arg =~ TIME_REGEX}
|
32
|
+
Time.now.change({hour: hour, min: minute, sec: 0})
|
33
|
+
end
|
34
|
+
default
|
35
|
+
end
|
36
|
+
|
37
|
+
class << self
|
38
|
+
def inherited(subclass)
|
39
|
+
register subclass.name.split("::").last.underscore.gsub("_"," "), subclass
|
40
|
+
end
|
41
|
+
|
42
|
+
def register(name, klass)
|
43
|
+
@commands[name] = klass
|
44
|
+
end
|
45
|
+
|
46
|
+
def get(name, book, arguments = [])
|
47
|
+
klass = @commands[name]
|
48
|
+
klass.new(book, arguments) if klass
|
49
|
+
end
|
50
|
+
|
51
|
+
def known
|
52
|
+
@commands.keys
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# register all commands
|
59
|
+
Dir[File.dirname(__FILE__) + '/command/*.rb'].each {|file| require file }
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Timelog
|
2
|
+
class Command
|
3
|
+
class Active < Command
|
4
|
+
def execute
|
5
|
+
if entry = book.entries.active
|
6
|
+
client = (entry.client and book.clients.exists?(entry.client)) ? book.clients.name(entry.client) : nil
|
7
|
+
project = (client and entry.project and book.projects.exists?(client, entry.project)) ? book.projects.name(client, entry.project) : nil
|
8
|
+
description = "%s%s" % [client || "n/a", project ? ": #{project}" : ""]
|
9
|
+
"%s - %02d:%02d:%02d#{" (paused)" if entry.paused?}" % ([description] + entry.active_time.values)
|
10
|
+
else
|
11
|
+
"no active entry"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def help
|
16
|
+
"usage: timelog active"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Timelog
|
2
|
+
class Command
|
3
|
+
class Archive < Command
|
4
|
+
def execute
|
5
|
+
entries = book.entries.where(arguments.first || "non-archived")
|
6
|
+
entries.delete_if {|e| not e.stopped? or e.archived}
|
7
|
+
if entries.size == 0
|
8
|
+
"no entries found for archiving"
|
9
|
+
else
|
10
|
+
entries.each do |e|
|
11
|
+
book.entries.archive e
|
12
|
+
end
|
13
|
+
"#{entries.length} #{entries.length == 1 ? "entry" : "entries"} archived"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def help
|
18
|
+
"usage: timelog archive [<query>]"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module Timelog
|
2
|
+
class Command
|
3
|
+
class ClientAdd < Command
|
4
|
+
def execute
|
5
|
+
raise InvalidArgumentError if arguments.size > 2
|
6
|
+
raise MissingClientNameError unless name = arguments.shift
|
7
|
+
|
8
|
+
book.clients.add name, arguments.shift
|
9
|
+
"client #{name} added"
|
10
|
+
end
|
11
|
+
|
12
|
+
def help
|
13
|
+
"usage: timelog client add <name> [<shortcut>]"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
module Timelog
|
2
|
+
class Command
|
3
|
+
class List < Command
|
4
|
+
def execute
|
5
|
+
table = book.entries.where(arguments.first || "non-archived").map do |e|
|
6
|
+
{
|
7
|
+
id: e.id,
|
8
|
+
client: e.client ? book.clients.name(e.client) : "",
|
9
|
+
project: e.project ? book.projects.name(e.client, e.project) : "",
|
10
|
+
description: e.description,
|
11
|
+
started: e.started.try(:strftime, "%Y-%m-%d %H:%M"),
|
12
|
+
stopped: e.stopped.try(:strftime, "%Y-%m-%d %H:%M"),
|
13
|
+
"active time" => "%02d:%02d:%02d" % e.active_time.values,
|
14
|
+
"paused time" => e.paused_seconds > 0 ? "%02d:%02d:%02d" % e.paused_time.values : "",
|
15
|
+
"total time" => "%02d:%02d:%02d" % e.total_time.values,
|
16
|
+
status: e.status
|
17
|
+
}
|
18
|
+
end
|
19
|
+
|
20
|
+
if table.size == 0
|
21
|
+
"no entries found"
|
22
|
+
else
|
23
|
+
sizes = column_sizes_for table
|
24
|
+
width = sizes.values.reduce(:+) + sizes.length * 3 + 1
|
25
|
+
|
26
|
+
separater = ''
|
27
|
+
width.times { separater << '-' }
|
28
|
+
|
29
|
+
lines = [separater]
|
30
|
+
lines << "|" + sizes.map do |name, length|
|
31
|
+
" %-#{length}s " % name
|
32
|
+
end.join("|") + "|"
|
33
|
+
lines << separater
|
34
|
+
|
35
|
+
table.each do |row|
|
36
|
+
line = []
|
37
|
+
row.each do |key, value|
|
38
|
+
line << " %-#{sizes[key]}s " % value
|
39
|
+
end
|
40
|
+
lines << "|" + line.join("|") + "|"
|
41
|
+
end
|
42
|
+
lines << separater
|
43
|
+
|
44
|
+
lines.join("\n")
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def help
|
49
|
+
"usage: timelog list [<query>]"
|
50
|
+
end
|
51
|
+
|
52
|
+
def column_sizes_for(table)
|
53
|
+
columns = {}
|
54
|
+
table.each do |row|
|
55
|
+
row.each do |key, value|
|
56
|
+
columns[key] ||= key.size
|
57
|
+
columns[key] = value.to_s.size if value and value.to_s.size > columns[key]
|
58
|
+
end
|
59
|
+
end
|
60
|
+
columns
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Timelog
|
2
|
+
class Command
|
3
|
+
class Pause < Command
|
4
|
+
def execute
|
5
|
+
entry = book.entries.active_or_new
|
6
|
+
book.entries.pause entry, at: time_in_arguments
|
7
|
+
"entry paused"
|
8
|
+
end
|
9
|
+
|
10
|
+
def help
|
11
|
+
"usage: timelog pause [<time>]"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Timelog
|
2
|
+
class Command
|
3
|
+
class ProjectAdd < Command
|
4
|
+
def execute
|
5
|
+
raise InvalidArgumentError if arguments.size > 3
|
6
|
+
raise MissingClientNameError unless client = arguments.shift
|
7
|
+
raise MissingProjectNameError unless name = arguments.shift
|
8
|
+
|
9
|
+
book.projects.add client, name, arguments.shift
|
10
|
+
"project #{name} added"
|
11
|
+
end
|
12
|
+
|
13
|
+
def help
|
14
|
+
"usage: timelog project add <client> <name> [<shortcut>]"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Timelog
|
2
|
+
class Command
|
3
|
+
class Resume < Command
|
4
|
+
def execute
|
5
|
+
entry = book.entries.active_or_new
|
6
|
+
book.entries.resume entry, at: time_in_arguments
|
7
|
+
"entry resumed"
|
8
|
+
end
|
9
|
+
|
10
|
+
def help
|
11
|
+
"usage: timelog resume [<time>]"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Timelog
|
2
|
+
class Command
|
3
|
+
class Start < Command
|
4
|
+
def execute
|
5
|
+
time = time_in_arguments
|
6
|
+
raise InvalidArgumentError if arguments.size > 2
|
7
|
+
|
8
|
+
client = arguments.shift
|
9
|
+
project = arguments.shift
|
10
|
+
|
11
|
+
client = book.clients.id(client) unless client.nil?
|
12
|
+
project = book.projects.id(client, project) unless project.nil?
|
13
|
+
|
14
|
+
entry = book.entries.active_or_new
|
15
|
+
entry.client = client
|
16
|
+
entry.project = project
|
17
|
+
|
18
|
+
book.entries.start entry, at: time
|
19
|
+
"entry started"
|
20
|
+
end
|
21
|
+
|
22
|
+
def help
|
23
|
+
"usage: timelog start [<client>] [<project>] [<time>]"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Timelog
|
2
|
+
class Command
|
3
|
+
class Stop < Command
|
4
|
+
def execute
|
5
|
+
time = time_in_arguments
|
6
|
+
|
7
|
+
entry = book.entries.active_or_new
|
8
|
+
entry.description = arguments.join(" ")
|
9
|
+
|
10
|
+
book.entries.stop entry, at: time
|
11
|
+
"entry stopped"
|
12
|
+
end
|
13
|
+
|
14
|
+
def help
|
15
|
+
"usage: timelog stop [<description>] [<time>]"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Timelog
|
2
|
+
class Configuration
|
3
|
+
attr_accessor :database, :callbacks
|
4
|
+
|
5
|
+
def initialize
|
6
|
+
# defaults
|
7
|
+
@database = "#{Dir.home}/.timelog.db"
|
8
|
+
@callbacks = {before: {}, after: {}}
|
9
|
+
end
|
10
|
+
|
11
|
+
def set(name, value)
|
12
|
+
send("#{name}=", value)
|
13
|
+
end
|
14
|
+
|
15
|
+
def before(event, &block)
|
16
|
+
@callbacks[:before][event.to_sym] ||= []
|
17
|
+
@callbacks[:before][event.to_sym] << block if block_given?
|
18
|
+
@callbacks[:before][event.to_sym]
|
19
|
+
end
|
20
|
+
|
21
|
+
def after(event, &block)
|
22
|
+
@callbacks[:after][event.to_sym] ||= []
|
23
|
+
@callbacks[:after][event.to_sym] << block if block_given?
|
24
|
+
@callbacks[:after][event.to_sym]
|
25
|
+
end
|
26
|
+
|
27
|
+
class << self
|
28
|
+
def open(file = nil, &block)
|
29
|
+
instance = self.new
|
30
|
+
instance.instance_eval(File.read(file), file) if file and File.exists?(file)
|
31
|
+
instance.instance_eval(&block) if block_given?
|
32
|
+
instance
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'sqlite3'
|
2
|
+
|
3
|
+
module Timelog
|
4
|
+
class Database
|
5
|
+
def initialize(configuration)
|
6
|
+
@configuration = configuration
|
7
|
+
@database = SQLite3::Database.new @configuration.database
|
8
|
+
update_to_latest_version!
|
9
|
+
end
|
10
|
+
|
11
|
+
def execute(sql, bind_vars = [], *args, &block)
|
12
|
+
@database.execute(sql, bind_vars, *args, &block)
|
13
|
+
end
|
14
|
+
|
15
|
+
def first_row(sql, *bind_vars)
|
16
|
+
@database.get_first_row(sql, *bind_vars)
|
17
|
+
end
|
18
|
+
|
19
|
+
def first_value(sql, *bind_vars)
|
20
|
+
@database.get_first_value(sql, *bind_vars)
|
21
|
+
end
|
22
|
+
|
23
|
+
def first_column(sql, *bind_vars)
|
24
|
+
@database.execute(sql, *bind_vars).map {|row| row.first}
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def update_to_latest_version!
|
30
|
+
# create version table (if not already exists)
|
31
|
+
@database.execute "create table if not exists version (number int)"
|
32
|
+
|
33
|
+
# get current version
|
34
|
+
current = @database.get_first_value("select number from version")
|
35
|
+
unless current
|
36
|
+
@database.execute "insert into version values(0)"
|
37
|
+
current = 0
|
38
|
+
end
|
39
|
+
|
40
|
+
versions = {
|
41
|
+
1 => "create table entries (id integer primary key, client integer, project integer, description text, started integer, stopped integer)",
|
42
|
+
2 => "alter table entries add column pauses text",
|
43
|
+
3 => "alter table entries add column archived integer not null default 0",
|
44
|
+
4 => "create table clients (id integer primary key, name text not null unique, shortcut text unique)",
|
45
|
+
5 => "create table projects (id integer primary key, client integer not null, name text not null, shortcut text)"
|
46
|
+
}
|
47
|
+
|
48
|
+
# run version migrations
|
49
|
+
versions.each {|version, query| @database.execute(query) if current < version }
|
50
|
+
|
51
|
+
# update version
|
52
|
+
@database.execute("update version set number = ?", versions.keys.last)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
module Timelog
|
2
|
+
class Entry
|
3
|
+
include ActiveModel::Validations
|
4
|
+
|
5
|
+
attr_accessor :id, :client, :project, :description
|
6
|
+
attr_reader :started, :stopped, :pauses, :archived
|
7
|
+
|
8
|
+
alias :archived? :archived
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@pauses = {}
|
12
|
+
@archived = false
|
13
|
+
end
|
14
|
+
|
15
|
+
def started=(at)
|
16
|
+
@started = at
|
17
|
+
end
|
18
|
+
|
19
|
+
def stopped=(at)
|
20
|
+
resume if paused?
|
21
|
+
@stopped = at
|
22
|
+
end
|
23
|
+
|
24
|
+
def started?
|
25
|
+
@started
|
26
|
+
end
|
27
|
+
|
28
|
+
def stopped?
|
29
|
+
@stopped
|
30
|
+
end
|
31
|
+
|
32
|
+
def pause(at = Time.now)
|
33
|
+
raise EntryAlreadyPaused if paused?
|
34
|
+
@pauses[at] = nil
|
35
|
+
end
|
36
|
+
|
37
|
+
def resume(at = Time.now)
|
38
|
+
raise EntryNotPaused unless paused?
|
39
|
+
@pauses[@pauses.keys.last] = at
|
40
|
+
end
|
41
|
+
|
42
|
+
def paused?
|
43
|
+
@pauses.length > 0 and @pauses.values.last.nil?
|
44
|
+
end
|
45
|
+
|
46
|
+
def date
|
47
|
+
started.try(:to_date)
|
48
|
+
end
|
49
|
+
|
50
|
+
def active_seconds
|
51
|
+
total_seconds - paused_seconds
|
52
|
+
end
|
53
|
+
|
54
|
+
def paused_seconds
|
55
|
+
pauses.inject(0) do |seconds, pause|
|
56
|
+
seconds + ((pause[1] || Time.now) - pause[0])
|
57
|
+
end.to_i
|
58
|
+
end
|
59
|
+
|
60
|
+
def total_seconds
|
61
|
+
return 0 unless started?
|
62
|
+
((stopped || Time.now) - started).round
|
63
|
+
end
|
64
|
+
|
65
|
+
def active_time
|
66
|
+
parse_seconds(active_seconds)
|
67
|
+
end
|
68
|
+
|
69
|
+
def paused_time
|
70
|
+
parse_seconds(paused_seconds)
|
71
|
+
end
|
72
|
+
|
73
|
+
def total_time
|
74
|
+
parse_seconds(total_seconds)
|
75
|
+
end
|
76
|
+
|
77
|
+
def archived=(value)
|
78
|
+
@archived = value ? true : false
|
79
|
+
end
|
80
|
+
|
81
|
+
def status
|
82
|
+
return "archived" if archived?
|
83
|
+
return "paused" if paused?
|
84
|
+
return "active" if active?
|
85
|
+
end
|
86
|
+
|
87
|
+
def active?
|
88
|
+
!stopped?
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
def parse_seconds(seconds)
|
94
|
+
hours = seconds / 60 / 60
|
95
|
+
minutes = (seconds - hours * 60 * 60) / 60
|
96
|
+
seconds = (seconds - hours * 60 * 60) - (minutes * 60)
|
97
|
+
{hours: hours, minutes: minutes, seconds: seconds}
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
data/timelog.gemspec
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/timelog/version', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |gem|
|
5
|
+
gem.authors = ["Sune Kibsgaard Pedersen"]
|
6
|
+
gem.email = ["sune@kibs.dk"]
|
7
|
+
gem.description = %q{Time logger}
|
8
|
+
gem.summary = %q{Time logger}
|
9
|
+
gem.homepage = ""
|
10
|
+
|
11
|
+
gem.files = `git ls-files`.split($\)
|
12
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
13
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
14
|
+
gem.name = "timelog"
|
15
|
+
gem.require_paths = ["lib"]
|
16
|
+
gem.version = Timelog::VERSION
|
17
|
+
gem.add_dependency "sqlite3", "~> 1.3"
|
18
|
+
gem.add_dependency "activesupport", "= 3.2.11"
|
19
|
+
gem.add_dependency "activemodel", "= 3.2.11"
|
20
|
+
gem.add_development_dependency "pry"
|
21
|
+
end
|
metadata
ADDED
@@ -0,0 +1,138 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: timelog
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Sune Kibsgaard Pedersen
|
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: sqlite3
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '1.3'
|
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.3'
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: activesupport
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - '='
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: 3.2.11
|
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: 3.2.11
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: activemodel
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
none: false
|
50
|
+
requirements:
|
51
|
+
- - '='
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: 3.2.11
|
54
|
+
type: :runtime
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - '='
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 3.2.11
|
62
|
+
- !ruby/object:Gem::Dependency
|
63
|
+
name: pry
|
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: Time logger
|
79
|
+
email:
|
80
|
+
- sune@kibs.dk
|
81
|
+
executables:
|
82
|
+
- timelog
|
83
|
+
extensions: []
|
84
|
+
extra_rdoc_files: []
|
85
|
+
files:
|
86
|
+
- .gitignore
|
87
|
+
- Gemfile
|
88
|
+
- LICENSE
|
89
|
+
- README.md
|
90
|
+
- Rakefile
|
91
|
+
- bin/timelog
|
92
|
+
- lib/timelog.rb
|
93
|
+
- lib/timelog/book.rb
|
94
|
+
- lib/timelog/book/clients.rb
|
95
|
+
- lib/timelog/book/entries.rb
|
96
|
+
- lib/timelog/book/projects.rb
|
97
|
+
- lib/timelog/command.rb
|
98
|
+
- lib/timelog/command/active.rb
|
99
|
+
- lib/timelog/command/archive.rb
|
100
|
+
- lib/timelog/command/client_add.rb
|
101
|
+
- lib/timelog/command/help.rb
|
102
|
+
- lib/timelog/command/list.rb
|
103
|
+
- lib/timelog/command/pause.rb
|
104
|
+
- lib/timelog/command/project_add.rb
|
105
|
+
- lib/timelog/command/resume.rb
|
106
|
+
- lib/timelog/command/start.rb
|
107
|
+
- lib/timelog/command/stop.rb
|
108
|
+
- lib/timelog/configuration.rb
|
109
|
+
- lib/timelog/database.rb
|
110
|
+
- lib/timelog/entry.rb
|
111
|
+
- lib/timelog/version.rb
|
112
|
+
- timelog.gemspec
|
113
|
+
homepage: ''
|
114
|
+
licenses: []
|
115
|
+
post_install_message:
|
116
|
+
rdoc_options: []
|
117
|
+
require_paths:
|
118
|
+
- lib
|
119
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
120
|
+
none: false
|
121
|
+
requirements:
|
122
|
+
- - ! '>='
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
126
|
+
none: false
|
127
|
+
requirements:
|
128
|
+
- - ! '>='
|
129
|
+
- !ruby/object:Gem::Version
|
130
|
+
version: '0'
|
131
|
+
requirements: []
|
132
|
+
rubyforge_project:
|
133
|
+
rubygems_version: 1.8.24
|
134
|
+
signing_key:
|
135
|
+
specification_version: 3
|
136
|
+
summary: Time logger
|
137
|
+
test_files: []
|
138
|
+
has_rdoc:
|