timelog 0.0.1
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 +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:
|