recurify 0.0.0 → 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.
- checksums.yaml +4 -4
- data/README.md +42 -0
- data/lib/recurify/errors.rb +6 -0
- data/lib/recurify/rule.rb +132 -0
- data/lib/recurify/rule_analysis.rb +43 -0
- data/lib/recurify/rule_translation.rb +99 -0
- data/lib/recurify/version.rb +1 -1
- data/lib/recurify.rb +2 -0
- metadata +65 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a343e37a72dc1fc5319317532fe1af000e31c41e
|
4
|
+
data.tar.gz: d50087d4a127bce77547365be1b4b2a2f694b640
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 93687456b41c7422e1731da5205dc337664e2ddf205b3210f80421b9a0c81940b185db9f05835b5422bfcf2d787fb9be99c5a90b2edeb44652a35993ddd3838c
|
7
|
+
data.tar.gz: 14ce0c66d4a0e9c77c2ee94fd50c59b0c73cd78f5c97239f30d103338444f60b5cb2254002a0b3582a131e2c79341d021152cebfd7231a6b923c0cff4b64d6b9
|
data/README.md
CHANGED
@@ -1,6 +1,48 @@
|
|
1
1
|
# Recurify
|
2
2
|
|
3
|
+
[](https://badge.fury.io/rb/recurify)
|
3
4
|
[](https://travis-ci.org/cs/recurify)
|
5
|
+
[](https://codeclimate.com/github/cs/recurify)
|
6
|
+
|
7
|
+
**Recurify is very much "work in progress" and shoudn't be used yet.**
|
8
|
+
|
9
|
+
Recurify is a simple, light-weight, evaluator for Recurrence Rules. It takes a
|
10
|
+
Recurrence Rule as input and returns an [`Enumerable`][enumerable], containing
|
11
|
+
all [`Date`][date] objects resulting from this Rule in chronlogical order.
|
12
|
+
|
13
|
+
[enumerable]: http://ruby-doc.org/core-2.2.3/Enumerable.html
|
14
|
+
[date]: http://ruby-doc.org/stdlib-2.2.3/libdoc/date/rdoc/Date.html
|
15
|
+
|
16
|
+
## Contributions
|
17
|
+
|
18
|
+
1. **Everything has to be tested.** No excuses. Pending tests are forbidden.
|
19
|
+
2. Ensure the project's test suite is still passing (run `rspec`).
|
20
|
+
3. Ensure the code is still passing all rubocop checks (run `rubocop`).
|
21
|
+
4. Document your changes.
|
22
|
+
5. All commits messages have to be prefixed as follows:
|
23
|
+
* `F` - **F**eature commits with functional changes.
|
24
|
+
Example commit message: `F implement Rule#normalize`.
|
25
|
+
* `B` - **B**ugfix commits for fixing broken stuff.
|
26
|
+
Example commit message: `B consider Rule#count in Rule#==`
|
27
|
+
* `R` - **R**efactoring commits without functional changes.
|
28
|
+
Example commit message: `R memoize Rule#normalize for performance`.
|
29
|
+
* `C` - **C**hore commits are for everything else.
|
30
|
+
Example commit message: `C upgrade to latest version of rubocop`.
|
31
|
+
|
32
|
+
Prefixed commit messages may seem a bit strange at first. The intention is
|
33
|
+
simply to keep functional and non-functional changes clearly apart. Therefore,
|
34
|
+
`R` and `C` commits must **never** modify the code in `lib` and in `spec` at the
|
35
|
+
same time.
|
36
|
+
|
37
|
+
## Sponsor
|
38
|
+
|
39
|
+
Recurify is sponsored by [Shore][shore]. They are currently [hiring][shore-jobs]
|
40
|
+
Ruby, JavaScript, iOS, and Android developers for their headquarters in
|
41
|
+
Munich/Germany as well as their top-notch development teams in Sofia/Bulgaria
|
42
|
+
and Madrid/Spain.
|
43
|
+
|
44
|
+
[shore]: http://www.shore.com
|
45
|
+
[shore-jobs]: http://www.shore.com/en/careers
|
4
46
|
|
5
47
|
## License
|
6
48
|
|
@@ -0,0 +1,132 @@
|
|
1
|
+
require_relative 'rule_analysis'
|
2
|
+
require_relative 'rule_translation'
|
3
|
+
|
4
|
+
module Recurify
|
5
|
+
# Represents a Recurrence Rule. Note that +Rule+ objects are *immutable* once
|
6
|
+
# they are instantiated (i.e. they are value objects).
|
7
|
+
#
|
8
|
+
# @!attribute [r] frequency
|
9
|
+
# @return [String]
|
10
|
+
# @!attribute [r] interval
|
11
|
+
# @return [Fixnum]
|
12
|
+
# @!attribute [r] count
|
13
|
+
# @return [Fixnum, nil]
|
14
|
+
# @!attribute [r] starts_on
|
15
|
+
# @return [Date]
|
16
|
+
# @!attribute [r] ends_on
|
17
|
+
# @return [Date, nil]
|
18
|
+
class Rule
|
19
|
+
include RuleAnalysis
|
20
|
+
include RuleTranslation
|
21
|
+
|
22
|
+
BASE_FREQUENCIES = %w(daily monthly).freeze
|
23
|
+
SUGAR_FREQUENCIES = %w(weekly quarterly yearly).freeze
|
24
|
+
SUPPORTED_FREQUENCIES = (BASE_FREQUENCIES + SUGAR_FREQUENCIES).freeze
|
25
|
+
MIN_INTERVAL = 1
|
26
|
+
MIN_COUNT = 1
|
27
|
+
|
28
|
+
attr_reader :frequency, :interval, :count, :starts_on, :ends_on
|
29
|
+
|
30
|
+
# Returns a new instance of +Rule+.
|
31
|
+
#
|
32
|
+
# @param attributes [Hash<Symbol,Object>] attributes for the new +Rule+
|
33
|
+
# @option attributes [#to_s] :frequency
|
34
|
+
# @option attributes [#to_i] :interval
|
35
|
+
# @option attributes [#to_i, nil] :count
|
36
|
+
# @option attributes [#to_date] :starts_on
|
37
|
+
# @option attributes [#to_date, nil] :ends_on
|
38
|
+
#
|
39
|
+
# @raise [InvalidRuleFrequency] if the provided +#frequency+ is not
|
40
|
+
# supported
|
41
|
+
# @raise [InvalidRuleInterval] if the provided +#interval+ is not supported
|
42
|
+
# @raise [InvalidRuleCount] if the provided +#count+ is not supported
|
43
|
+
def initialize(attributes = {})
|
44
|
+
self.attributes = self.class.default_attributes.merge(attributes)
|
45
|
+
validate!
|
46
|
+
end
|
47
|
+
|
48
|
+
# Creates a new instance of +Rule+ with substituted attributes. Attributes
|
49
|
+
# that haven't been specified are copied from +self+.
|
50
|
+
#
|
51
|
+
# @see #initialize
|
52
|
+
#
|
53
|
+
# @param substitutions [Hash<Symbol,Object>] for the the new +Rule+
|
54
|
+
# @return [Rule]
|
55
|
+
def substitute(substitutions = {})
|
56
|
+
self.class.new(attributes.merge(substitutions))
|
57
|
+
end
|
58
|
+
|
59
|
+
# Tests for equality with +other+.
|
60
|
+
#
|
61
|
+
# Two +Rule+ objects are considered to be equal, if and only if they both
|
62
|
+
# evaluate to the same same set of +Date+ objects.
|
63
|
+
#
|
64
|
+
# @param other [Rule]
|
65
|
+
# @return [Boolean]
|
66
|
+
def ==(other)
|
67
|
+
normalize.attributes == other.normalize.attributes
|
68
|
+
end
|
69
|
+
|
70
|
+
# Convert +self+ to a +Hash+, representing the same +Rule+. Note, that
|
71
|
+
# +#attributes+ is essentially the inverse of +#initialize+.
|
72
|
+
#
|
73
|
+
# @return [Hash<Symbol,Object>]
|
74
|
+
def attributes
|
75
|
+
@_attributes ||= {
|
76
|
+
frequency: frequency,
|
77
|
+
interval: interval,
|
78
|
+
count: count,
|
79
|
+
starts_on: starts_on,
|
80
|
+
ends_on: ends_on
|
81
|
+
}
|
82
|
+
end
|
83
|
+
|
84
|
+
private
|
85
|
+
|
86
|
+
def self.default_attributes
|
87
|
+
{
|
88
|
+
frequency: SUPPORTED_FREQUENCIES.first,
|
89
|
+
interval: MIN_INTERVAL,
|
90
|
+
count: nil,
|
91
|
+
starts_on: Date.today,
|
92
|
+
ends_on: nil
|
93
|
+
}
|
94
|
+
end
|
95
|
+
|
96
|
+
def attributes=(attributes)
|
97
|
+
@frequency = attributes[:frequency].to_s
|
98
|
+
@interval = attributes[:interval].to_i
|
99
|
+
@count = attributes[:count].nil? ? nil : attributes[:count].to_i
|
100
|
+
@starts_on = attributes[:starts_on].to_date
|
101
|
+
@ends_on = attributes[:ends_on].nil? ? nil : attributes[:ends_on].to_date
|
102
|
+
end
|
103
|
+
|
104
|
+
# @return [void]
|
105
|
+
def validate!
|
106
|
+
validate_frequency!
|
107
|
+
validate_interval!
|
108
|
+
validate_count!
|
109
|
+
end
|
110
|
+
|
111
|
+
# @raise [InvalidRuleFrequency]
|
112
|
+
# @return [void]
|
113
|
+
def validate_frequency!
|
114
|
+
return if SUPPORTED_FREQUENCIES.include?(frequency)
|
115
|
+
fail InvalidRuleFrequency, "'#{frequency}' is not supported"
|
116
|
+
end
|
117
|
+
|
118
|
+
# @raise [InvalidRuleInterval]
|
119
|
+
# @return [void]
|
120
|
+
def validate_interval!
|
121
|
+
return if interval >= MIN_INTERVAL
|
122
|
+
fail InvalidRuleInterval, "'#{interval}' is not supported"
|
123
|
+
end
|
124
|
+
|
125
|
+
# @raise [InvalidRuleCount]
|
126
|
+
# @return [void]
|
127
|
+
def validate_count!
|
128
|
+
return if count.nil? || count >= MIN_COUNT
|
129
|
+
fail InvalidRuleCount, "'#{count}' is not supported"
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module Recurify
|
2
|
+
module RuleAnalysis # :nodoc:
|
3
|
+
# Returns +true+ if +self+ is normalized. By definition, a +Rule+ is
|
4
|
+
# normalized if and only if it is not denormalized.
|
5
|
+
#
|
6
|
+
# @see #denormalized?
|
7
|
+
#
|
8
|
+
# @return [Boolean]
|
9
|
+
def normalized?
|
10
|
+
Rule::BASE_FREQUENCIES.include?(frequency)
|
11
|
+
end
|
12
|
+
|
13
|
+
# Returns +true+ if +self+ is denormalized. By definition, a +Rule+ is
|
14
|
+
# denormalized if and only if it is not normalized.
|
15
|
+
#
|
16
|
+
# @see #normalized?
|
17
|
+
#
|
18
|
+
# @return [Boolean]
|
19
|
+
def denormalized?
|
20
|
+
!normalized?
|
21
|
+
end
|
22
|
+
|
23
|
+
# Returns +true+ if +self+ is finite. By definition, a +Rule+ is finite if
|
24
|
+
# and only if it is not infinite.
|
25
|
+
#
|
26
|
+
# @see #infinite?
|
27
|
+
#
|
28
|
+
# @retrun [Boolean]
|
29
|
+
def finite?
|
30
|
+
!count.nil? || !ends_on.nil?
|
31
|
+
end
|
32
|
+
|
33
|
+
# Returns +true+ if +self+ is infinite. By definition, a +Rule+ is infinite
|
34
|
+
# if and only if it is not finite.
|
35
|
+
#
|
36
|
+
# @see #finite?
|
37
|
+
#
|
38
|
+
# @return [Boolean]
|
39
|
+
def infinite?
|
40
|
+
!finite?
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
module Recurify
|
2
|
+
module RuleTranslation # :nodoc:
|
3
|
+
NORMALIZATION_MATRIX = {
|
4
|
+
# Normalization is a no-op for base-frequencies ...
|
5
|
+
'daily' => { target_frequency: 'daily', interval_multiplier: 1 },
|
6
|
+
'monthly' => { target_frequency: 'monthly', interval_multiplier: 1 },
|
7
|
+
# But, it is more interesting for sugar-frequencies ...
|
8
|
+
'weekly' => { target_frequency: 'daily', interval_multiplier: 7 },
|
9
|
+
'quarterly' => { target_frequency: 'monthly', interval_multiplier: 3 },
|
10
|
+
'yearly' => { target_frequency: 'monthly', interval_multiplier: 12 }
|
11
|
+
}.freeze
|
12
|
+
|
13
|
+
DENORMALIZATION_MATRIX = {
|
14
|
+
'daily' => [
|
15
|
+
# Applicable denormalizitions ordered by decreasing preferability ...
|
16
|
+
{ target_frequency: 'weekly', interval_divsor: 7 },
|
17
|
+
{ target_frequency: 'daily', interval_divsor: 1 }
|
18
|
+
],
|
19
|
+
'weekly' => [
|
20
|
+
# Applicable denormalizitions ordered by decreasing preferability ...
|
21
|
+
{ target_frequency: 'weekly', interval_divsor: 1 }
|
22
|
+
],
|
23
|
+
'monthly' => [
|
24
|
+
# Applicable denormalizitions ordered by decreasing preferability ...
|
25
|
+
{ target_frequency: 'yearly', interval_divsor: 12 },
|
26
|
+
{ target_frequency: 'quarterly', interval_divsor: 3 },
|
27
|
+
{ target_frequency: 'monthly', interval_divsor: 1 }
|
28
|
+
],
|
29
|
+
'quarterly' => [
|
30
|
+
# Applicable denormalizitions ordered by decreasing preferability ...
|
31
|
+
{ target_frequency: 'yearly', interval_divsor: 4 },
|
32
|
+
{ target_frequency: 'quarterly', interval_divsor: 1 }
|
33
|
+
],
|
34
|
+
'yearly' => [
|
35
|
+
# Applicable denormalizitions ordered by decreasing preferability ...
|
36
|
+
{ target_frequency: 'yearly', interval_divsor: 1 }
|
37
|
+
]
|
38
|
+
}.freeze
|
39
|
+
|
40
|
+
# Creates a new +Rule+, similar to +self+, but normalized. Note that
|
41
|
+
# +#normalize+ is idempotent.
|
42
|
+
#
|
43
|
+
# Normalization means that +Rule+s with +#frequency+ ...
|
44
|
+
# * ... "weekly" are translated to +Rule+s with +#frequency+ "daily".
|
45
|
+
# * ... "quarterly" are translated to +Rule+s with +#frequency+ "monthly".
|
46
|
+
# * ... "yearly" are translated to +Rule+s with +#frequency+ "monthly".
|
47
|
+
#
|
48
|
+
# Additional translations may be added in the future.
|
49
|
+
#
|
50
|
+
# @return [Rule]
|
51
|
+
def normalize
|
52
|
+
@_normalized_rule ||= begin
|
53
|
+
translate_with = NORMALIZATION_MATRIX[frequency]
|
54
|
+
|
55
|
+
substitute(
|
56
|
+
frequency: translate_with[:target_frequency],
|
57
|
+
interval: interval * translate_with[:interval_multiplier]
|
58
|
+
)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Creates a new +Rule+, similar to +self+, but denormalized. Note that
|
63
|
+
# +#denormalized+ is idempotent.
|
64
|
+
#
|
65
|
+
# Denormalizion means that +Rule+s with +#frequency+ ...
|
66
|
+
# * ... "daily" are translated to +Rule+s with +#frequency+ "weekly".
|
67
|
+
# * ... "monthly" are translated to +Rule+s with +#frequency+ "yearly".
|
68
|
+
# * ... "monthly" are translated to +Rule+s with +#frequency+ "quarterly".
|
69
|
+
#
|
70
|
+
# Additional translations may be added in the future.
|
71
|
+
#
|
72
|
+
# Why +#denormalize+ at all? The idea is that denormalized +Rule+s are
|
73
|
+
# easier to parse for humans. For instance, "every 7th week" is easier to
|
74
|
+
# understand than "every 49th day".
|
75
|
+
#
|
76
|
+
# @return [Rule]
|
77
|
+
def denormalize
|
78
|
+
@_denormalized_rule ||= begin
|
79
|
+
translate_with = find_best_denormalization
|
80
|
+
|
81
|
+
substitute(
|
82
|
+
frequency: translate_with[:target_frequency],
|
83
|
+
interval: interval / translate_with[:interval_divsor]
|
84
|
+
)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
private
|
89
|
+
|
90
|
+
# @see DENORMALIZATION_MATRIX
|
91
|
+
# @return [Hash<Symbol, Object>]
|
92
|
+
def find_best_denormalization
|
93
|
+
contenders = DENORMALIZATION_MATRIX[frequency]
|
94
|
+
contenders.find do |contender|
|
95
|
+
normalize.interval % contender[:interval_divsor] == 0
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
data/lib/recurify/version.rb
CHANGED
data/lib/recurify.rb
CHANGED
metadata
CHANGED
@@ -1,15 +1,71 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: recurify
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Christoph Schiessl
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2015-
|
12
|
-
dependencies:
|
11
|
+
date: 2015-12-01 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rspec
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.4'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3.4'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rubocop
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 0.35.1
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 0.35.1
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: yard
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 0.8.7
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 0.8.7
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: redcarpet
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 3.3.3
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: 3.3.3
|
13
69
|
description:
|
14
70
|
email: cs@proactive.cc
|
15
71
|
executables: []
|
@@ -19,13 +75,18 @@ files:
|
|
19
75
|
- LICENSE
|
20
76
|
- README.md
|
21
77
|
- lib/recurify.rb
|
78
|
+
- lib/recurify/errors.rb
|
79
|
+
- lib/recurify/rule.rb
|
80
|
+
- lib/recurify/rule_analysis.rb
|
81
|
+
- lib/recurify/rule_translation.rb
|
22
82
|
- lib/recurify/version.rb
|
23
83
|
homepage: https://github.com/cs/recurify
|
24
84
|
licenses:
|
25
85
|
- MIT
|
26
86
|
metadata: {}
|
27
87
|
post_install_message:
|
28
|
-
rdoc_options:
|
88
|
+
rdoc_options:
|
89
|
+
- "--markup-provider=redcarpet"
|
29
90
|
require_paths:
|
30
91
|
- lib
|
31
92
|
required_ruby_version: !ruby/object:Gem::Requirement
|