now-task-manager 0.1.0

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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +66 -0
  3. data/bin/git-commit-pomodoro +4 -0
  4. data/bin/pomodoro +246 -0
  5. data/doc/benefits.md +91 -0
  6. data/doc/bitbar.md +2 -0
  7. data/doc/curses.md +5 -0
  8. data/doc/example/rules.rb +7 -0
  9. data/doc/formats/scheduled.md +32 -0
  10. data/doc/formats/today.md +115 -0
  11. data/doc/getting-started.md +41 -0
  12. data/doc/integrations.md +68 -0
  13. data/doc/rules.md +0 -0
  14. data/doc/schedules.md +0 -0
  15. data/doc/terms.md +32 -0
  16. data/doc/workflow.md +60 -0
  17. data/lib/pomodoro.rb +6 -0
  18. data/lib/pomodoro/commands/bitbar.rb +87 -0
  19. data/lib/pomodoro/config.rb +30 -0
  20. data/lib/pomodoro/exts/hour.rb +63 -0
  21. data/lib/pomodoro/formats/scheduled.rb +37 -0
  22. data/lib/pomodoro/formats/scheduled/parser/parser.rb +26 -0
  23. data/lib/pomodoro/formats/scheduled/parser/transformer.rb +21 -0
  24. data/lib/pomodoro/formats/scheduled/task_group.rb +40 -0
  25. data/lib/pomodoro/formats/scheduled/task_list.rb +90 -0
  26. data/lib/pomodoro/formats/today.rb +12 -0
  27. data/lib/pomodoro/formats/today/formatter.rb +26 -0
  28. data/lib/pomodoro/formats/today/parser/parser.rb +73 -0
  29. data/lib/pomodoro/formats/today/parser/transformer.rb +65 -0
  30. data/lib/pomodoro/formats/today/task.rb +92 -0
  31. data/lib/pomodoro/formats/today/task/dynamic_additions.rb +5 -0
  32. data/lib/pomodoro/formats/today/task/metadata.rb +37 -0
  33. data/lib/pomodoro/formats/today/task/statuses.rb +63 -0
  34. data/lib/pomodoro/formats/today/task_list.rb +50 -0
  35. data/lib/pomodoro/formats/today/time_frame.rb +113 -0
  36. data/lib/pomodoro/schedule/dsl.rb +68 -0
  37. data/lib/pomodoro/scheduler.rb +46 -0
  38. 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