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.
@@ -0,0 +1,18 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ dev
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in timelog.gemspec
4
+ gemspec
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.
@@ -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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
@@ -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
@@ -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
@@ -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,6 @@
1
+ module Timelog
2
+ class Command
3
+ # just to make sure the the global help is shown if help is put as an command
4
+ class Help < Command; end
5
+ end
6
+ 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
@@ -0,0 +1,3 @@
1
+ module Timelog
2
+ VERSION = "0.0.1"
3
+ end
@@ -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: