scheduled-format 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +63 -0
- data/lib/scheduled-format.rb +37 -0
- data/lib/scheduled-format/parser/parser.rb +46 -0
- data/lib/scheduled-format/parser/transformer.rb +26 -0
- data/lib/scheduled-format/task.rb +39 -0
- data/lib/scheduled-format/task_group.rb +101 -0
- data/lib/scheduled-format/task_list.rb +106 -0
- metadata +78 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: c1fcfd8366043e95ad3620345cc368848c36e2b08b3c3671d3189f19696c7030
|
4
|
+
data.tar.gz: efe64d9385bbd041ac75ac18c10d094b73e490c038b0c742e554a0b3b8f10ecf
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 8b922da0a4298956a10e7fa8ca89b11ef5e945667bb6bac19d1ed3e9a3538402184789f35002e8b7e3a4735e074f39d80f629b433bd4ab363a264b9fce111619
|
7
|
+
data.tar.gz: 905a9715919ea3ff1dddbcd2adb3f11f076d06eb55351e5fd41350bd9e6ee664efd3325c014e3abb897d1170de1eebbe8b738feed84066fafd12ad8aa750f1ae
|
data/README.md
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
# About
|
2
|
+
|
3
|
+
[![Gem version][GV img]][Gem version]
|
4
|
+
[![Build status][BS img]][Build status]
|
5
|
+
[![Coverage status][CS img]][Coverage status]
|
6
|
+
[![CodeClimate status][CC img]][CodeClimate status]
|
7
|
+
[![YARD documentation][YD img]][YARD documentation]
|
8
|
+
|
9
|
+
This format is used to store **scheduled tasks**: tasks that will be done later
|
10
|
+
or in a certain context.
|
11
|
+
|
12
|
+
# API
|
13
|
+
|
14
|
+
```ruby
|
15
|
+
require 'import'
|
16
|
+
|
17
|
+
simple_format = import('simple-format')
|
18
|
+
simple_format.parse(File.read('tasks.todo'))
|
19
|
+
```
|
20
|
+
|
21
|
+
# Format
|
22
|
+
|
23
|
+
```
|
24
|
+
Tomorrow
|
25
|
+
- Buy milk. #errands
|
26
|
+
- [9:20] Call with Mike.
|
27
|
+
|
28
|
+
Prague
|
29
|
+
- Pick up my shoes. #errands
|
30
|
+
```
|
31
|
+
|
32
|
+
# Currently unsupported
|
33
|
+
|
34
|
+
- **Labels**. Labels allow us to match tasks with _named_ time frames.
|
35
|
+
See [#8](https://github.com/botanicus/now-task-manager/issues/8).
|
36
|
+
|
37
|
+
```
|
38
|
+
- ADM: Catch up with Eva.
|
39
|
+
```
|
40
|
+
|
41
|
+
# Intentionally unsupported
|
42
|
+
|
43
|
+
- **Comments**. I want to keep the format simple and the task file small.
|
44
|
+
Every time there was something like comments, the file bloated uncontrollably.
|
45
|
+
- **Task formatting**. Task is a string, it doesn't recognise any structures within.
|
46
|
+
Therefore, anything you can fit in to a line will be the task body. So you can
|
47
|
+
put anything that {Pomodoro::Formats::Today} supports such as scheduled times
|
48
|
+
and tags.
|
49
|
+
|
50
|
+
_For more details about the format see
|
51
|
+
[parser_spec.rb](https://github.com/botanicus/scheduled-format/blob/master/spec/scheduled-format/parser/parser_spec.rb)._
|
52
|
+
|
53
|
+
[Gem version]: https://rubygems.org/gems/scheduled-format
|
54
|
+
[Build status]: https://travis-ci.org/botanicus/scheduled-format
|
55
|
+
[Coverage status]: https://coveralls.io/github/botanicus/scheduled-format
|
56
|
+
[CodeClimate status]: https://codeclimate.com/github/botanicus/scheduled-format/maintainability
|
57
|
+
[YARD documentation]: http://www.rubydoc.info/github/botanicus/scheduled-format/master
|
58
|
+
|
59
|
+
[GV img]: https://badge.fury.io/rb/scheduled-format.svg
|
60
|
+
[BS img]: https://travis-ci.org/botanicus/scheduled-format.svg?branch=master
|
61
|
+
[CS img]: https://img.shields.io/coveralls/botanicus/scheduled-format.svg
|
62
|
+
[CC img]: https://api.codeclimate.com/v1/badges/a99a88d28ad37a79dbf6/maintainability
|
63
|
+
[YD img]: http://img.shields.io/badge/yard-docs-blue.svg
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'parslet'
|
2
|
+
require 'parslet/convenience' # parse_with_debug
|
3
|
+
|
4
|
+
Parser = import('scheduled-format/parser/parser')
|
5
|
+
Transformer = import('scheduled-format/parser/transformer')
|
6
|
+
TaskList = import('scheduled-format/task_list')
|
7
|
+
|
8
|
+
# {include:file:doc/formats/scheduled.md}
|
9
|
+
# The entry point method for parsing this format.
|
10
|
+
#
|
11
|
+
# @param string [String] string in the scheduled task list format
|
12
|
+
# @return [TaskList, nil]
|
13
|
+
#
|
14
|
+
# @example
|
15
|
+
# scheduled_format = import('scheduled-format')
|
16
|
+
#
|
17
|
+
# task_list = scheduled_format.parse <<-EOF.gsub(/^\s*/, '')
|
18
|
+
# Tomorrow
|
19
|
+
# - Buy milk. #errands
|
20
|
+
# - [9:20] Call with Mike.
|
21
|
+
#
|
22
|
+
# Prague
|
23
|
+
# - Pick up my shoes. #errands
|
24
|
+
# EOF
|
25
|
+
# @since 0.2
|
26
|
+
def exports.parse(string_or_io)
|
27
|
+
string = string_or_io.respond_to?(:read) ? string_or_io.read : string_or_io
|
28
|
+
tree = Parser.new.parse_with_debug(string)
|
29
|
+
nodes = Transformer.new.apply(tree)
|
30
|
+
TaskList.new(nodes.empty? ? Array.new : nodes)
|
31
|
+
end
|
32
|
+
|
33
|
+
export parser: Parser,
|
34
|
+
transformer: Transformer,
|
35
|
+
task: import('scheduled-format/task'),
|
36
|
+
task_list: TaskList,
|
37
|
+
task_group: import('scheduled-format/task_group')
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require 'parslet'
|
2
|
+
|
3
|
+
# @api private
|
4
|
+
class Parser < Parslet::Parser
|
5
|
+
rule(:hour_strict) {
|
6
|
+
(match['\d'].repeat(1) >> str(':') >> match['\d'].repeat(2, 2)).as(:hour)
|
7
|
+
}
|
8
|
+
|
9
|
+
rule(:start_time) {
|
10
|
+
str('[') >> hour_strict.as(:start_time) >> str(']') >> str(' ').maybe
|
11
|
+
}
|
12
|
+
|
13
|
+
rule(:time_frame) {
|
14
|
+
# TODO: (hour_strict.absent? >> match['^\]\n'] >> any)
|
15
|
+
str('[') >> match['\w '].repeat.as(:str) >> str(']') >> str(' ').maybe
|
16
|
+
}
|
17
|
+
|
18
|
+
rule(:header) {
|
19
|
+
# match['^\n'].repeat # This makes it hang!
|
20
|
+
(str("\n").absent? >> any).repeat(1).as(:str) >> str("\n")
|
21
|
+
}
|
22
|
+
|
23
|
+
rule(:task_body) do
|
24
|
+
(match['#\n'].absent? >> any).repeat.as(:str)
|
25
|
+
end
|
26
|
+
|
27
|
+
rule(:tag) do
|
28
|
+
str('#') >> match['^\s'].repeat.as(:str) >> str(' ').maybe
|
29
|
+
end
|
30
|
+
|
31
|
+
rule(:task) {
|
32
|
+
str('- ') >> (time_frame.as(:time_frame).maybe >> start_time.maybe >> task_body.as(:body) >> tag.as(:tag).repeat.as(:tags).maybe).as(:task) >> str("\n").repeat
|
33
|
+
}
|
34
|
+
|
35
|
+
rule(:task_group) {
|
36
|
+
(header.as(:header) >> task.repeat.as(:tasks)).as(:task_group)
|
37
|
+
}
|
38
|
+
|
39
|
+
rule(:task_groups) {
|
40
|
+
task_group.repeat(0)
|
41
|
+
}
|
42
|
+
|
43
|
+
root(:task_groups)
|
44
|
+
end
|
45
|
+
|
46
|
+
export { Parser }
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'parslet'
|
2
|
+
require 'refined-refinements/hour'
|
3
|
+
|
4
|
+
Task = import('scheduled-format/task')
|
5
|
+
TaskGroup = import('scheduled-format/task_group')
|
6
|
+
|
7
|
+
# @api private
|
8
|
+
class Transformer < Parslet::Transform
|
9
|
+
rule(str: simple(:slice)) { slice.to_s.strip }
|
10
|
+
|
11
|
+
rule(tag: simple(:slice)) { slice.to_sym }
|
12
|
+
|
13
|
+
rule(hour: simple(:hour_string)) do
|
14
|
+
Hour.parse(hour_string.to_s)
|
15
|
+
end
|
16
|
+
|
17
|
+
rule(task: subtree(:data)) do
|
18
|
+
Task.new(**data)
|
19
|
+
end
|
20
|
+
|
21
|
+
rule(task_group: subtree(:data)) {
|
22
|
+
TaskGroup.new(**data)
|
23
|
+
}
|
24
|
+
end
|
25
|
+
|
26
|
+
export { Transformer }
|
@@ -0,0 +1,39 @@
|
|
1
|
+
class Task
|
2
|
+
attr_reader :body, :time_frame, :start_time, :tags
|
3
|
+
|
4
|
+
# Create a new scheduled task.
|
5
|
+
#
|
6
|
+
# @param body [String] the task description.
|
7
|
+
# @param start_time [Hour] when the task starts.
|
8
|
+
# @param time_frame [String] name, initials or an abbreviation of a time frame
|
9
|
+
# to which the task will be scheduled.
|
10
|
+
# @param tags [Array<Symbol>] list of tags.
|
11
|
+
def initialize(body:, time_frame: nil, start_time: nil, tags: Array.new)
|
12
|
+
@body, @time_frame, @start_time, @tags = body, time_frame, start_time, tags
|
13
|
+
|
14
|
+
if body.empty?
|
15
|
+
raise ArgumentError.new("Body cannot be empty.")
|
16
|
+
end
|
17
|
+
|
18
|
+
if start_time && ! start_time.is_a?(Hour)
|
19
|
+
raise ArgumentError.new("Hour instance was expected, got #{start_time.class}")
|
20
|
+
end
|
21
|
+
|
22
|
+
if ! tags.empty? && tags.any? { |tag| ! tag.is_a?(Symbol) }
|
23
|
+
raise ArgumentError.new("Tags are supposed to be an array of symbols.")
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Format task in the {Pomodoro::Formats::Scheduled scheduled task list format}.
|
28
|
+
# @since 0.2
|
29
|
+
def to_s
|
30
|
+
[
|
31
|
+
'-',
|
32
|
+
("[#{@time_frame}]" if @time_frame),
|
33
|
+
("[#{@start_time}]" if @start_time),
|
34
|
+
"#{@body}", *@tags.map { |tag| "##{tag}"}
|
35
|
+
].compact.join(' ')
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
export { Task }
|
@@ -0,0 +1,101 @@
|
|
1
|
+
require 'date'
|
2
|
+
|
3
|
+
class TaskGroup
|
4
|
+
# @since 0.2
|
5
|
+
attr_reader :header, :tasks
|
6
|
+
|
7
|
+
# @param header [String] header of the task group.
|
8
|
+
# @param tasks [Array<String>] tasks of the group.
|
9
|
+
# @since 0.2
|
10
|
+
#
|
11
|
+
# @example
|
12
|
+
# TaskGroup = import('scheduled-format/task_group')
|
13
|
+
#
|
14
|
+
# tasks = ['Buy milk. #errands', '[9:20] Call with Mike.']
|
15
|
+
# group = TaskGroup.new(header: 'Tomorrow', tasks: tasks)
|
16
|
+
def initialize(header:, tasks: Array.new)
|
17
|
+
@header, @tasks = header, tasks
|
18
|
+
if tasks.any? { |task| ! task.is_a?(Task) }
|
19
|
+
raise ArgumentError.new("Task objects expected.")
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Add a task to the task group.
|
24
|
+
#
|
25
|
+
# @since 0.2
|
26
|
+
def <<(task)
|
27
|
+
unless task.is_a?(Task)
|
28
|
+
raise ArgumentError.new("Task expected, got #{task.class}.")
|
29
|
+
end
|
30
|
+
|
31
|
+
@tasks << task unless @tasks.map(&:to_s).include?(task.to_s)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Remove a task from the task group.
|
35
|
+
#
|
36
|
+
# @since 0.2
|
37
|
+
def delete(task)
|
38
|
+
unless task.is_a?(Task)
|
39
|
+
raise ArgumentError.new("Task expected, got #{task.class}.")
|
40
|
+
end
|
41
|
+
|
42
|
+
@tasks.delete_if { |t2| t2.to_s == task.to_s }
|
43
|
+
end
|
44
|
+
|
45
|
+
# Return a scheduled task list formatted string.
|
46
|
+
#
|
47
|
+
# @since 0.2
|
48
|
+
def to_s
|
49
|
+
[@header, @tasks.map(&:to_s), nil].flatten.join("\n")
|
50
|
+
end
|
51
|
+
|
52
|
+
def save(path)
|
53
|
+
data = self.to_s
|
54
|
+
File.open(path, 'w:utf-8') do |file|
|
55
|
+
file.puts(data)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# TODO: Next Monday
|
60
|
+
# NOTE: For parsing we don't use %-d etc, only %d.
|
61
|
+
DATE_FORMATS = {
|
62
|
+
'%d/%m' => :next_month, # 1/1
|
63
|
+
'%d/%m/%Y' => nil, # 1/1/2018
|
64
|
+
'%A %d/%m' => :next_week, # Monday 1/1 Note: Higher specifity has to come first.
|
65
|
+
'%A' => :next_week # Monday
|
66
|
+
}
|
67
|
+
|
68
|
+
# labels = ['Tomorrow', date.strftime('%A'), date.strftime('%-d/%m'), date.strftime('%-d/%m/%Y')]
|
69
|
+
def scheduled_date
|
70
|
+
return Date.today if @header == 'Today' # Change tomorrow to Today if you're generating it in the morning.
|
71
|
+
return Date.today + 1 if @header == 'Tomorrow'
|
72
|
+
parse_date_in_the_future(@header)
|
73
|
+
end
|
74
|
+
|
75
|
+
def tomorrow?
|
76
|
+
self.scheduled_date == Date.today + 1
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
# TODO: We need base_date, Date.today wouldn't cut it if we run "now g +3".
|
82
|
+
def parse_date_in_the_future(header)
|
83
|
+
DATE_FORMATS.each do |format, adjustment_method|
|
84
|
+
begin
|
85
|
+
date = Date.strptime(header, format)
|
86
|
+
date.define_singleton_method(:next_week) { self + 7 } # TODO: DataExts, extract it from commands.rb.
|
87
|
+
return ensure_in_the_future(date, adjustment_method)
|
88
|
+
rescue ArgumentError
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
return nil
|
93
|
+
end
|
94
|
+
|
95
|
+
def ensure_in_the_future(date, adjustment_method)
|
96
|
+
return date if adjustment_method.nil? || Date.today <= date
|
97
|
+
date.send(adjustment_method)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
export { TaskGroup }
|
@@ -0,0 +1,106 @@
|
|
1
|
+
class TaskList
|
2
|
+
include Enumerable
|
3
|
+
|
4
|
+
# List of {TaskGroup task groups}. Or more precisely objects responding to `#header` and `#tasks`.
|
5
|
+
# @since 0.2
|
6
|
+
attr_reader :data
|
7
|
+
|
8
|
+
# @param [Array<TaskGroup>] data List of task groups.
|
9
|
+
# Or more precisely objects responding to `#header` and `#tasks`.
|
10
|
+
# @raise [ArgumentError] if data is not an array or if its content doesn't
|
11
|
+
# respond to `#header` and `#tasks`.
|
12
|
+
#
|
13
|
+
# @example
|
14
|
+
# TaskGroup, TaskList = import('scheduled-format').grab(:TaskGroup, :TaskList)
|
15
|
+
#
|
16
|
+
# tasks = ['Buy milk. #errands', '[9:20] Call with Mike.']
|
17
|
+
# group = TaskGroup.new(header: 'Tomorrow', tasks: tasks)
|
18
|
+
# list = TaskList.new([group])
|
19
|
+
# @since 0.2
|
20
|
+
def initialize(data)
|
21
|
+
@data = data
|
22
|
+
|
23
|
+
unless data.is_a?(Array) && data.all? { |item| item.respond_to?(:header) && item.respond_to?(:tasks) }
|
24
|
+
raise ArgumentError.new("Data is supposed to be an array of TaskGroup instances.")
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Find a task group that matches given header.
|
29
|
+
#
|
30
|
+
# @return [TaskGroup, nil] matching the header.
|
31
|
+
# @since 0.2
|
32
|
+
#
|
33
|
+
# @example
|
34
|
+
# # Using the code from the initialiser.
|
35
|
+
# list['Tomorrow']
|
36
|
+
def [](header)
|
37
|
+
@data.find do |task_group|
|
38
|
+
task_group.header == header
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Add a task group onto the task list.
|
43
|
+
#
|
44
|
+
# @raise [ArgumentError] if the task group is already in the list.
|
45
|
+
# @param [TaskGroup] task_group the task group.
|
46
|
+
# @since 0.2
|
47
|
+
def <<(task_group)
|
48
|
+
unless task_group.is_a?(TaskGroup)
|
49
|
+
raise ArgumentError.new("TaskGroup expected, got #{task_group.class}.")
|
50
|
+
end
|
51
|
+
|
52
|
+
if self[task_group.header]
|
53
|
+
raise ArgumentError.new("Task group with header #{task_group.header} is already on the list.")
|
54
|
+
end
|
55
|
+
|
56
|
+
@data << task_group
|
57
|
+
self.sort!
|
58
|
+
end
|
59
|
+
|
60
|
+
def scheduled_task_groups
|
61
|
+
@data.group_by { |tg| tg.scheduled_date.nil? }[false] || Array.new
|
62
|
+
end
|
63
|
+
|
64
|
+
def non_scheduled_task_groups
|
65
|
+
@data.group_by { |tg| tg.scheduled_date.nil? }[true] || Array.new
|
66
|
+
end
|
67
|
+
|
68
|
+
def sort!
|
69
|
+
@data = self.scheduled_task_groups.sort_by(&:scheduled_date) +
|
70
|
+
self.non_scheduled_task_groups
|
71
|
+
|
72
|
+
self
|
73
|
+
end
|
74
|
+
|
75
|
+
# Remove a task group from the task list.
|
76
|
+
#
|
77
|
+
# @param [TaskGroup] task_group the task group.
|
78
|
+
# @since 0.2
|
79
|
+
def delete(task_group)
|
80
|
+
@data.delete(task_group)
|
81
|
+
end
|
82
|
+
|
83
|
+
# Iterate over the task groups.
|
84
|
+
#
|
85
|
+
# @yieldparam [TaskGroup] task_group
|
86
|
+
# @since 0.2
|
87
|
+
def each(&block)
|
88
|
+
@data.each(&block)
|
89
|
+
end
|
90
|
+
|
91
|
+
# Return a scheduled task list formatted string.
|
92
|
+
#
|
93
|
+
# @since 0.2
|
94
|
+
def to_s
|
95
|
+
@data.map(&:to_s).join("\n")
|
96
|
+
end
|
97
|
+
|
98
|
+
def save(path)
|
99
|
+
data = self.to_s
|
100
|
+
File.open(path, 'w:utf-8') do |file|
|
101
|
+
file.puts(data)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
export { TaskList }
|
metadata
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: scheduled-format
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- James C Russell
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2018-06-07 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: commonjs_modules
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: parslet
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.8'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.8'
|
41
|
+
description: "."
|
42
|
+
email: james@101ideas.cz
|
43
|
+
executables: []
|
44
|
+
extensions: []
|
45
|
+
extra_rdoc_files: []
|
46
|
+
files:
|
47
|
+
- README.md
|
48
|
+
- lib/scheduled-format.rb
|
49
|
+
- lib/scheduled-format/parser/parser.rb
|
50
|
+
- lib/scheduled-format/parser/transformer.rb
|
51
|
+
- lib/scheduled-format/task.rb
|
52
|
+
- lib/scheduled-format/task_group.rb
|
53
|
+
- lib/scheduled-format/task_list.rb
|
54
|
+
homepage: http://github.com/botanicus/scheduled-format
|
55
|
+
licenses:
|
56
|
+
- MIT
|
57
|
+
metadata: {}
|
58
|
+
post_install_message:
|
59
|
+
rdoc_options: []
|
60
|
+
require_paths:
|
61
|
+
- lib
|
62
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
63
|
+
requirements:
|
64
|
+
- - ">="
|
65
|
+
- !ruby/object:Gem::Version
|
66
|
+
version: '0'
|
67
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
68
|
+
requirements:
|
69
|
+
- - ">="
|
70
|
+
- !ruby/object:Gem::Version
|
71
|
+
version: '0'
|
72
|
+
requirements: []
|
73
|
+
rubyforge_project:
|
74
|
+
rubygems_version: 2.7.6
|
75
|
+
signing_key:
|
76
|
+
specification_version: 4
|
77
|
+
summary: ''
|
78
|
+
test_files: []
|