recurify 0.0.0 → 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![Gem Version](https://badge.fury.io/rb/recurify.svg)](https://badge.fury.io/rb/recurify)
|
3
4
|
[![Build Status](https://img.shields.io/travis/cs/recurify/master.svg)](https://travis-ci.org/cs/recurify)
|
5
|
+
[![Code Climate](https://codeclimate.com/github/cs/recurify/badges/gpa.svg)](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
|