now-task-manager 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +66 -0
- data/bin/git-commit-pomodoro +4 -0
- data/bin/pomodoro +246 -0
- data/doc/benefits.md +91 -0
- data/doc/bitbar.md +2 -0
- data/doc/curses.md +5 -0
- data/doc/example/rules.rb +7 -0
- data/doc/formats/scheduled.md +32 -0
- data/doc/formats/today.md +115 -0
- data/doc/getting-started.md +41 -0
- data/doc/integrations.md +68 -0
- data/doc/rules.md +0 -0
- data/doc/schedules.md +0 -0
- data/doc/terms.md +32 -0
- data/doc/workflow.md +60 -0
- data/lib/pomodoro.rb +6 -0
- data/lib/pomodoro/commands/bitbar.rb +87 -0
- data/lib/pomodoro/config.rb +30 -0
- data/lib/pomodoro/exts/hour.rb +63 -0
- data/lib/pomodoro/formats/scheduled.rb +37 -0
- data/lib/pomodoro/formats/scheduled/parser/parser.rb +26 -0
- data/lib/pomodoro/formats/scheduled/parser/transformer.rb +21 -0
- data/lib/pomodoro/formats/scheduled/task_group.rb +40 -0
- data/lib/pomodoro/formats/scheduled/task_list.rb +90 -0
- data/lib/pomodoro/formats/today.rb +12 -0
- data/lib/pomodoro/formats/today/formatter.rb +26 -0
- data/lib/pomodoro/formats/today/parser/parser.rb +73 -0
- data/lib/pomodoro/formats/today/parser/transformer.rb +65 -0
- data/lib/pomodoro/formats/today/task.rb +92 -0
- data/lib/pomodoro/formats/today/task/dynamic_additions.rb +5 -0
- data/lib/pomodoro/formats/today/task/metadata.rb +37 -0
- data/lib/pomodoro/formats/today/task/statuses.rb +63 -0
- data/lib/pomodoro/formats/today/task_list.rb +50 -0
- data/lib/pomodoro/formats/today/time_frame.rb +113 -0
- data/lib/pomodoro/schedule/dsl.rb +68 -0
- data/lib/pomodoro/scheduler.rb +46 -0
- metadata +124 -0
@@ -0,0 +1,63 @@
|
|
1
|
+
class Hour
|
2
|
+
def self.parse(string)
|
3
|
+
hours, minutes = string.split(':')
|
4
|
+
self.new(hours.to_i, minutes.to_i)
|
5
|
+
end
|
6
|
+
|
7
|
+
def self.now
|
8
|
+
now = Time.now
|
9
|
+
self.new(now.hour, now.min)
|
10
|
+
end
|
11
|
+
|
12
|
+
attr_reader :minutes
|
13
|
+
def initialize(hours, minutes = 0)
|
14
|
+
@minutes = (hours * 60) + minutes
|
15
|
+
end
|
16
|
+
|
17
|
+
[:+, :-].each do |method_name|
|
18
|
+
define_method(method_name) do |hour_or_minutes|
|
19
|
+
if hour_or_minutes.is_a?(self.class)
|
20
|
+
self.class.new(0, @minutes.send(method_name, hour_or_minutes.minutes))
|
21
|
+
elsif hour_or_minutes.is_a?(Integer)
|
22
|
+
self.class.new(0, @minutes.send(method_name, hour_or_minutes))
|
23
|
+
else
|
24
|
+
raise TypeError.new
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def hours
|
30
|
+
if (@minutes / 60).round > (@minutes / 60)
|
31
|
+
(@minutes / 60).round - 1
|
32
|
+
else
|
33
|
+
(@minutes / 60).round
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Currently unused, but it might be in the future.
|
38
|
+
# def *(rate)
|
39
|
+
# (@minutes * (rate / 60.0)).round(2)
|
40
|
+
# end
|
41
|
+
|
42
|
+
[:==, :eql?, :<, :<=, :>, :>=, :<=>].each do |method_name|
|
43
|
+
define_method(method_name) do |anotherHour|
|
44
|
+
if anotherHour.is_a?(self.class)
|
45
|
+
self.minutes.send(method_name, anotherHour.minutes)
|
46
|
+
elsif anotherHour.is_a?(Time)
|
47
|
+
self.send(method_name, Hour.now)
|
48
|
+
else
|
49
|
+
raise TypeError.new("#{self.class}##{method_name} expects #{self.class} or Time object.")
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def inspect
|
55
|
+
"#{self.hours}:#{format('%02d', self.minutes_over_the_hour)}"
|
56
|
+
end
|
57
|
+
alias_method :to_s, :inspect
|
58
|
+
|
59
|
+
protected
|
60
|
+
def minutes_over_the_hour
|
61
|
+
@minutes - (self.hours * 60)
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'parslet'
|
2
|
+
require 'parslet/convenience' # parse_with_debug
|
3
|
+
|
4
|
+
module Pomodoro
|
5
|
+
module Formats
|
6
|
+
# {include:file:doc/formats/scheduled.md}
|
7
|
+
module Scheduled
|
8
|
+
# The entry point method for parsing this format.
|
9
|
+
#
|
10
|
+
# @param string [String] string in the scheduled task list format
|
11
|
+
# @return [TaskList, nil]
|
12
|
+
#
|
13
|
+
# @example
|
14
|
+
# require 'pomodoro/formats/scheduled'
|
15
|
+
#
|
16
|
+
# task_list = Pomodoro::Formats::Scheduled.parse <<-EOF.gsub(/^\s*/, '')
|
17
|
+
# Tomorrow
|
18
|
+
# - Buy milk. #errands
|
19
|
+
# - [9:20] Call with Mike.
|
20
|
+
#
|
21
|
+
# Prague
|
22
|
+
# - Pick up my shoes. #errands
|
23
|
+
# EOF
|
24
|
+
# @since 1.0
|
25
|
+
def self.parse(string)
|
26
|
+
tree = Parser.new.parse_with_debug(string)
|
27
|
+
nodes = Transformer.new.apply(tree)
|
28
|
+
TaskList.new(nodes) unless nodes.empty?
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
require 'pomodoro/formats/scheduled/parser/parser'
|
35
|
+
require 'pomodoro/formats/scheduled/parser/transformer'
|
36
|
+
require 'pomodoro/formats/scheduled/task_list'
|
37
|
+
require 'pomodoro/formats/scheduled/task_group'
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'parslet'
|
2
|
+
require 'pomodoro/formats/scheduled'
|
3
|
+
|
4
|
+
module Pomodoro::Formats::Scheduled
|
5
|
+
# @api private
|
6
|
+
class Parser < Parslet::Parser
|
7
|
+
rule(:header) {
|
8
|
+
# match['^\n'].repeat # This makes it hang!
|
9
|
+
(str("\n").absent? >> any).repeat(1).as(:header) >> str("\n")
|
10
|
+
}
|
11
|
+
|
12
|
+
rule(:task) {
|
13
|
+
str('- ') >> match['^\n'].repeat.as(:task) >> str("\n").repeat
|
14
|
+
}
|
15
|
+
|
16
|
+
rule(:task_group) {
|
17
|
+
(header >> task.repeat.as(:task_list)).as(:task_group)
|
18
|
+
}
|
19
|
+
|
20
|
+
rule(:task_groups) {
|
21
|
+
task_group.repeat(0)
|
22
|
+
}
|
23
|
+
|
24
|
+
root(:task_groups)
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require 'parslet'
|
2
|
+
require 'pomodoro/formats/scheduled'
|
3
|
+
|
4
|
+
module Pomodoro::Formats::Scheduled
|
5
|
+
# @api private
|
6
|
+
class Transformer < Parslet::Transform
|
7
|
+
rule(task: simple(:task)) { task.to_s }
|
8
|
+
|
9
|
+
# Doesn't work. Has multiple keys by the way.
|
10
|
+
rule(header: simple(:header)) { header.to_s }
|
11
|
+
|
12
|
+
rule(task_group: subtree(:task_group)) {
|
13
|
+
options = {
|
14
|
+
header: task_group[:header].to_s,
|
15
|
+
tasks: task_group[:task_list]
|
16
|
+
}
|
17
|
+
|
18
|
+
TaskGroup.new(**options)
|
19
|
+
}
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module Pomodoro::Formats::Scheduled
|
2
|
+
class TaskGroup
|
3
|
+
# @since 1.0
|
4
|
+
attr_reader :header, :tasks
|
5
|
+
|
6
|
+
# @param header [String] header of the task group.
|
7
|
+
# @param tasks [Array<String>] tasks of the group.
|
8
|
+
# @since 1.0
|
9
|
+
#
|
10
|
+
# @example
|
11
|
+
# require 'pomodoro/formats/scheduled'
|
12
|
+
#
|
13
|
+
# tasks = ['Buy milk. #errands', '[9:20] Call with Mike.']
|
14
|
+
# group = Pomodoro::Formats::Scheduled::TaskGroup.new(header: 'Tomorrow', tasks: tasks)
|
15
|
+
def initialize(header:, tasks: Array.new)
|
16
|
+
@header, @tasks = header, tasks
|
17
|
+
end
|
18
|
+
|
19
|
+
# Add a task to the task group.
|
20
|
+
#
|
21
|
+
# @since 1.0
|
22
|
+
def <<(task)
|
23
|
+
@tasks << task unless @tasks.include?(task)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Remove a task from the task group.
|
27
|
+
#
|
28
|
+
# @since 1.0
|
29
|
+
def delete(task)
|
30
|
+
@tasks.delete(task)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Return a scheduled task list formatted string.
|
34
|
+
#
|
35
|
+
# @since 1.0
|
36
|
+
def to_s
|
37
|
+
[@header, @tasks.map { |task| "- #{task}" }, nil].flatten.join("\n")
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
module Pomodoro::Formats::Scheduled
|
2
|
+
class TaskList
|
3
|
+
include Enumerable
|
4
|
+
|
5
|
+
# List of {TaskGroup task groups}. Or more precisely objects responding to `#header` and `#tasks`.
|
6
|
+
# @since 1.0
|
7
|
+
attr_reader :data
|
8
|
+
|
9
|
+
# @param [Array<TaskGroup>] data List of task groups.
|
10
|
+
# Or more precisely objects responding to `#header` and `#tasks`.
|
11
|
+
# @raise [ArgumentError] if data is not an array or if its content doesn't
|
12
|
+
# respond to `#header` and `#tasks`.
|
13
|
+
#
|
14
|
+
# @example
|
15
|
+
# require 'pomodoro/formats/scheduled'
|
16
|
+
#
|
17
|
+
# tasks = ['Buy milk. #errands', '[9:20] Call with Mike.']
|
18
|
+
# group = Pomodoro::Formats::Scheduled::TaskGroup.new(header: 'Tomorrow', tasks: tasks)
|
19
|
+
# list = Pomodoro::Formats::Scheduled::TaskList.new([group])
|
20
|
+
# @since 1.0
|
21
|
+
def initialize(data)
|
22
|
+
@data = data
|
23
|
+
|
24
|
+
unless data.is_a?(Array) && data.all? { |item| item.respond_to?(:header) && item.respond_to?(:tasks) }
|
25
|
+
raise ArgumentError.new("Data is supposed to be an array of TaskGroup instances.")
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# Find a task group that matches given header.
|
30
|
+
#
|
31
|
+
# @return [TaskGroup, nil] matching the header.
|
32
|
+
# @since 1.0
|
33
|
+
#
|
34
|
+
# @example
|
35
|
+
# # Using the code from the initialiser.
|
36
|
+
# list['Tomorrow']
|
37
|
+
def [](header)
|
38
|
+
@data.find do |task_group|
|
39
|
+
task_group.header == header
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Add a task group onto the task list.
|
44
|
+
#
|
45
|
+
# @raise [ArgumentError] if the task group is already in the list.
|
46
|
+
# @param [TaskGroup] task_group the task group.
|
47
|
+
# @since 1.0
|
48
|
+
def <<(task_group)
|
49
|
+
if self[task_group.header]
|
50
|
+
raise ArgumentError.new("Task group with header #{task_group.header} is already on the list.")
|
51
|
+
end
|
52
|
+
|
53
|
+
@data << task_group
|
54
|
+
end
|
55
|
+
|
56
|
+
# Remove a task group from the task list.
|
57
|
+
#
|
58
|
+
# @param [TaskGroup] task_group the task group.
|
59
|
+
# @since 1.0
|
60
|
+
def delete(task_group)
|
61
|
+
@data.delete(task_group)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Iterate over the task groups.
|
65
|
+
#
|
66
|
+
# @yieldparam [TaskGroup] task_group
|
67
|
+
# @since 1.0
|
68
|
+
def each(&block)
|
69
|
+
@data.each(&block)
|
70
|
+
end
|
71
|
+
|
72
|
+
# Return a scheduled task list formatted string.
|
73
|
+
#
|
74
|
+
# @since 1.0
|
75
|
+
def to_s
|
76
|
+
@data.map(&:to_s).join("\n")
|
77
|
+
end
|
78
|
+
|
79
|
+
# Save scheduled task list formatted string into a file.
|
80
|
+
#
|
81
|
+
# @param [String] destination_path
|
82
|
+
# @since 1.0
|
83
|
+
def save(destination_path)
|
84
|
+
data = self.to_s
|
85
|
+
File.open(destination_path, 'w') do |file|
|
86
|
+
file.puts(data)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
module Pomodoro
|
2
|
+
module Formats
|
3
|
+
module Today
|
4
|
+
end
|
5
|
+
end
|
6
|
+
end
|
7
|
+
|
8
|
+
require 'pomodoro/formats/today/task_list'
|
9
|
+
require 'pomodoro/formats/today/time_frame'
|
10
|
+
require 'pomodoro/formats/today/task'
|
11
|
+
require 'pomodoro/formats/today/parser/parser'
|
12
|
+
require 'pomodoro/formats/today/parser/transformer'
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Pomodoro::Formats::Today
|
2
|
+
class Formatter
|
3
|
+
STATUS_SYMBOLS ||= {
|
4
|
+
# Unfinished statuses.
|
5
|
+
# Unstarted can be either tasks to be started,
|
6
|
+
# or tasks skipped when time frame changed.
|
7
|
+
unstarted: '-', in_progress: '-',
|
8
|
+
|
9
|
+
# maybe status :finished and :unfinished, but :unfinished would require Postponed/Deleted etc. (check dynamically)
|
10
|
+
# Finished, as in done for the day.
|
11
|
+
completed: '✔', progress_made: '✔', postponed: '✘', deleted: '✘'
|
12
|
+
}
|
13
|
+
|
14
|
+
def self.format(task)
|
15
|
+
output = [STATUS_SYMBOLS[self.status]]
|
16
|
+
if @start_time || @end_time
|
17
|
+
output << "[#{self.class.format_interval(@start_time, @end_time)}]"
|
18
|
+
else
|
19
|
+
output << "[#{@duration}]" unless @duration == DEFAULT_DURATION
|
20
|
+
end
|
21
|
+
output << @body
|
22
|
+
output << @tags.map { |tag| "##{tag}"}.join(' ') unless @tags.empty?
|
23
|
+
output.join(' ')
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
require 'parslet'
|
2
|
+
require 'pomodoro/formats/today'
|
3
|
+
|
4
|
+
module Pomodoro::Formats::Today
|
5
|
+
class Parser < Parslet::Parser
|
6
|
+
# Primitives.
|
7
|
+
rule(:integer) { match['0-9'].repeat(1) }
|
8
|
+
|
9
|
+
rule(:nl) { str("\n").repeat(1) }
|
10
|
+
rule(:nl_or_eof) { any.absent? | nl.maybe }
|
11
|
+
|
12
|
+
rule(:space) { match('\s').repeat(1) }
|
13
|
+
rule(:space?) { space.maybe }
|
14
|
+
|
15
|
+
rule(:lparen) { str('(') }
|
16
|
+
rule(:rparen) { str(')') }
|
17
|
+
rule(:time_delimiter) { match['-–'] }
|
18
|
+
rule(:colon) { str(':') }
|
19
|
+
|
20
|
+
rule(:hour) { (integer.repeat >> (colon >> integer).maybe).as(:hour) }
|
21
|
+
rule(:hour_strict) { (integer.repeat >> colon >> integer).as(:hour) }
|
22
|
+
rule(:indent) { match['-✓✔✕✖✗✘'].as(:indent).repeat(1, 1) >> space }
|
23
|
+
|
24
|
+
rule(:task_desc) { (match['#\n'].absent? >> any).repeat.as(:desc) }
|
25
|
+
rule(:time_frame_desc) { (match['(\n'].absent? >> any).repeat.as(:desc) }
|
26
|
+
# rule(:task_desc) { (str(' #').absent? >> match['^\n']).repeat.as(:desc) }
|
27
|
+
# rule(:time_frame_desc) { (str(' (').absent? >> match['^\n']).repeat.as(:desc) }
|
28
|
+
# rule(:task_desc) { (str("\n").absent? >> str(' #').absent? >> any).repeat.as(:desc) }
|
29
|
+
# rule(:time_frame_desc) { (str("\n").absent? >> str(' (').absent? >> any).repeat.as(:desc) }
|
30
|
+
|
31
|
+
rule(:tag) { str('#') >> match['^\s'].repeat.as(:tag) >> space? }
|
32
|
+
|
33
|
+
rule(:duration) do
|
34
|
+
# ✔ 9:20
|
35
|
+
# ✔ 9:20–10:00
|
36
|
+
# ✔ started at 9:20 (this is not the same as just 9:20)
|
37
|
+
# ✖ 9-10
|
38
|
+
# There was an issue with parsing that compared to 9 as duration.
|
39
|
+
(hour_strict.as(:start_time) >> (time_delimiter >> hour_strict.as(:end_time)).maybe) | str('started at') >> hour_strict.as(:start_time)
|
40
|
+
end
|
41
|
+
|
42
|
+
rule(:task_time_info) do
|
43
|
+
str('[') >> (duration | integer.as(:duration)) >> str(']') >> space
|
44
|
+
end
|
45
|
+
|
46
|
+
rule(:metadata) { (str("\n").absent? >> any).repeat.as(:line) }
|
47
|
+
|
48
|
+
rule(:task_body) { indent >> task_time_info.maybe >> task_desc >> tag.repeat }
|
49
|
+
rule(:metadata_block) { (nl >> str(' ') >> metadata).repeat(0) }
|
50
|
+
|
51
|
+
rule(:task) do
|
52
|
+
(task_body >> metadata_block).as(:task) >> nl.maybe # replaced nl_or_eof to fix the hang.
|
53
|
+
# IMPORTANT NOTE: nl.maybe is because the tag definition eats up \n's for unknown reason.
|
54
|
+
end
|
55
|
+
|
56
|
+
rule(:time_range) do
|
57
|
+
hour.as(:start_time) >> space? >> time_delimiter >> space? >> hour.as(:end_time)
|
58
|
+
end
|
59
|
+
|
60
|
+
rule(:time_from) do
|
61
|
+
(str('from') | str('after')) >> space >> hour.as(:start_time)
|
62
|
+
end
|
63
|
+
|
64
|
+
rule(:time_frame_header) do
|
65
|
+
time_frame_desc >> (lparen >> (time_range | time_from) >> rparen).maybe >> nl # replaced nl_or_eof to fix the hang.
|
66
|
+
end
|
67
|
+
|
68
|
+
rule(:time_frame_with_tasks) { (time_frame_header >> task.repeat.as(:task_list)).as(:time_frame) } # ...
|
69
|
+
rule(:time_frames_with_tasks) { time_frame_with_tasks.repeat(0) }
|
70
|
+
|
71
|
+
root(:time_frames_with_tasks)
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'parslet'
|
2
|
+
require 'pomodoro/exts/hour'
|
3
|
+
require 'pomodoro/formats/today'
|
4
|
+
|
5
|
+
module Pomodoro::Formats::Today
|
6
|
+
class Transformer < Parslet::Transform
|
7
|
+
STATUS_MAPPING ||= {
|
8
|
+
finished: ['✓', '✔', '☑'],
|
9
|
+
failed: ['✕', '☓', '✖', '✗', '✘', '☒'],
|
10
|
+
wip: ['☐', '⛶', '⚬']
|
11
|
+
}
|
12
|
+
|
13
|
+
rule(body: simple(:body)) {
|
14
|
+
{body: body.to_s.strip}
|
15
|
+
}
|
16
|
+
|
17
|
+
rule(duration: simple(:duration)) {
|
18
|
+
{duration: Integer(duration)}
|
19
|
+
}
|
20
|
+
|
21
|
+
rule(hour: simple(:hour_string)) {
|
22
|
+
Hour.parse(hour_string.to_s)
|
23
|
+
}
|
24
|
+
|
25
|
+
rule(tag: simple(:tag)) {
|
26
|
+
{tag: tag.to_sym}
|
27
|
+
}
|
28
|
+
|
29
|
+
rule(indent: simple(:char)) {
|
30
|
+
status, _ = Task::STATUS_MAPPING.find { |status, i| i.include?(char) }
|
31
|
+
{status: status}
|
32
|
+
}
|
33
|
+
|
34
|
+
rule(task: subtree(:hashes)) {
|
35
|
+
data = hashes.reduce(Hash.new) do |buffer, hash|
|
36
|
+
key = hash.keys.first
|
37
|
+
if buffer.has_key?(key)
|
38
|
+
buffer.merge(key => [buffer[key], hash[key]].flatten)
|
39
|
+
else
|
40
|
+
buffer.merge(hash)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
if data[:tag]
|
45
|
+
data[:tags] = [data.delete(:tag)].flatten
|
46
|
+
end
|
47
|
+
|
48
|
+
if data[:line]
|
49
|
+
data[:lines] = [data.delete(:line)].flatten
|
50
|
+
end
|
51
|
+
|
52
|
+
begin
|
53
|
+
Task.new(**data)
|
54
|
+
rescue ArgumentError => error
|
55
|
+
message = [error.message, "Arguments were: #{data.inspect}"].join("\n")
|
56
|
+
raise ArgumentError.new(message)
|
57
|
+
end
|
58
|
+
}
|
59
|
+
|
60
|
+
rule(time_frame: subtree(:data)) {
|
61
|
+
data[:body] = data.delete(:desc).to_s.strip # WTH? All the other nodes are processed correctly?
|
62
|
+
TimeFrame.new(**data)
|
63
|
+
}
|
64
|
+
end
|
65
|
+
end
|