enumdate 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c7941bd64bd2ffee84449e892558f43592866010fcd92d8809be7df94ec80f3a
4
+ data.tar.gz: f9ae67f04f0e5134700c4ad535d69f94f7e685607bb3fa8d9e69946c6610104b
5
+ SHA512:
6
+ metadata.gz: 0a2c4d506c93db39dd5e56ab5a562294d747e47977133191fe9682879dd1cfafb146214812a698da2837443e0a0aa32805e25c1991ebb0881b825eb596fc5929
7
+ data.tar.gz: eb6686a639cd867b90a938425abdb90f2465b2dc1fdefe79fb3d20a9ca1e66f477056a3a0b85d469e1e12794d94eec4771e63fcd781cb6e312d6169fd8cb8648
@@ -0,0 +1,16 @@
1
+ name: Ruby
2
+
3
+ on: [push,pull_request]
4
+
5
+ jobs:
6
+ build:
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - uses: actions/checkout@v2
10
+ - name: Set up Ruby
11
+ uses: ruby/setup-ruby@v1
12
+ with:
13
+ ruby-version: 3.0.2
14
+ bundler-cache: true
15
+ - name: Run the default task
16
+ run: bundle exec rake
data/.gitignore ADDED
@@ -0,0 +1,65 @@
1
+ ################################################################
2
+ # Added from https://raw.github.com/github/gitignore/master/Ruby.gitignore
3
+ #
4
+
5
+ *.gem
6
+ *.rbc
7
+ /.config
8
+ /coverage/
9
+ /InstalledFiles
10
+ /pkg/
11
+ /spec/reports/
12
+ /spec/examples.txt
13
+ /test/tmp/
14
+ /test/version_tmp/
15
+ /tmp/
16
+
17
+ # Used by dotenv library to load environment variables.
18
+ # .env
19
+
20
+ # Ignore Byebug command history file.
21
+ .byebug_history
22
+
23
+ ## Specific to RubyMotion:
24
+ .dat*
25
+ .repl_history
26
+ build/
27
+ *.bridgesupport
28
+ build-iPhoneOS/
29
+ build-iPhoneSimulator/
30
+
31
+ ## Specific to RubyMotion (use of CocoaPods):
32
+ #
33
+ # We recommend against adding the Pods directory to your .gitignore. However
34
+ # you should judge for yourself, the pros and cons are mentioned at:
35
+ # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
36
+ #
37
+ # vendor/Pods/
38
+
39
+ ## Documentation cache and generated files:
40
+ /.yardoc/
41
+ /_yardoc/
42
+ /doc/
43
+ /rdoc/
44
+
45
+ ## Environment normalization:
46
+ /.bundle/
47
+ /vendor/bundle
48
+ /lib/bundler/man/
49
+
50
+ # for a library or gem, you might want to ignore these files since the code is
51
+ # intended to run in multiple environments; otherwise, check them in:
52
+ Gemfile.lock
53
+ .ruby-version
54
+ .ruby-gemset
55
+
56
+ # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
57
+ .rvmrc
58
+
59
+ # Used by RuboCop. Remote config files pulled in from inherit_from directive.
60
+ .rubocop-https?--*
61
+
62
+ ################################################################
63
+ # Site-locals
64
+
65
+ attic/
data/.rubocop.yml ADDED
@@ -0,0 +1,32 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.5
3
+ NewCops: enable
4
+
5
+ Layout/LineLength:
6
+ Max: 120
7
+
8
+ Layout/MultilineMethodCallIndentation:
9
+ EnforcedStyle: indented_relative_to_receiver
10
+
11
+ Layout/SpaceInsideBlockBraces:
12
+ SpaceBeforeBlockParameters: false
13
+
14
+ Metrics/AbcSize:
15
+ Max: 25
16
+
17
+ Metrics/ParameterLists:
18
+ CountKeywordArgs: false
19
+
20
+ Style/Alias:
21
+ Enabled: true
22
+ EnforcedStyle: prefer_alias_method
23
+
24
+ Style/FormatStringToken:
25
+ Enabled: false
26
+
27
+ Style/ParallelAssignment:
28
+ Enabled: false
29
+
30
+ Style/StringLiterals:
31
+ Enabled: true
32
+ EnforcedStyle: double_quotes
data/.solargraph.yml ADDED
@@ -0,0 +1,21 @@
1
+ ---
2
+ include:
3
+ - "**/*.rb"
4
+ exclude:
5
+ - spec/**/*
6
+ - test/**/*
7
+ - vendor/**/*
8
+ - ".bundle/**/*"
9
+ require: []
10
+ domains: []
11
+ reporters:
12
+ - rubocop
13
+ formatter:
14
+ rubocop:
15
+ cops: safe
16
+ except: []
17
+ only: []
18
+ extra_args: []
19
+ require_paths: []
20
+ plugins: []
21
+ max_files: 5000
data/Gemfile ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in enumdate.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
9
+
10
+ gem "minitest", "~> 5.0"
11
+
12
+ gem "rubocop", "~> 1.7"
13
+
14
+ gem "solargraph"
15
+
16
+ gem "org-ruby"
17
+ gem "yard"
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2021 Yoshinari Nomura
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.org ADDED
@@ -0,0 +1,147 @@
1
+ #+TITLE: enumdate -- a small enumerator library to expand recurring dates
2
+ #+AUTHOR: Yoshinari Nomura
3
+ #+EMAIL:
4
+ #+DATE: 2021-08-21
5
+ #+OPTIONS: H:3 num:2 toc:nil
6
+ #+OPTIONS: ^:nil @:t \n:nil ::t |:t f:t TeX:t
7
+ #+OPTIONS: skip:nil
8
+ #+OPTIONS: author:t
9
+ #+OPTIONS: email:nil
10
+ #+OPTIONS: creator:nil
11
+ #+OPTIONS: timestamp:nil
12
+ #+OPTIONS: timestamps:nil
13
+ #+OPTIONS: d:nil
14
+ #+OPTIONS: tags:t
15
+ #+LANGUAGE: ja
16
+
17
+ [[https://badge.fury.io/rb/enumdate.svg]]
18
+ [[https://github.com/yoshinari-nomura/enumdate/actions/workflows/main.yml/badge.svg]]
19
+
20
+ ** Description
21
+ Enumdate is a small enumerator library to expand recurring dates.
22
+
23
+ You can get the latest version from:
24
+ + https://github.com/yoshinari-nomura/enumdate
25
+
26
+ ** How to use
27
+ *** Create enumerables
28
+ Thease a simple example to create enumerables:
29
+ #+begin_src ruby
30
+ # June 2021
31
+ # Su Mo Tu We Th Fr Sa
32
+ # 1 2 3 4 5
33
+ # 6 7 8 9 10 11 12
34
+ # 13 14 15 16 17 18 19
35
+ # 20 21 22 23 24 25 26
36
+ # 27 28 29 30
37
+ #
38
+ start = Date.new(2021, 6, 1) # first Tuesday June
39
+
40
+ # June 1st every year:
41
+ Enumdate.yearly_by_monthday(start).lazy.map(&:to_s).take(3).force
42
+ # => ["2021-06-01", "2022-06-01", "2023-06-01"]
43
+
44
+ # First Tuesday June every year:
45
+ Enumdate.yearly_by_day(start).lazy.map(&:to_s).take(3).force
46
+ # => ["2021-06-01", "2022-06-07", "2023-06-06"]
47
+
48
+ # 1st monthday every month:
49
+ Enumdate.monthly_by_monthday(start).lazy.map(&:to_s).take(3).force
50
+ # => ["2021-06-01", "2021-07-01", "2021-08-01"]
51
+
52
+ # First Tuesday every month:
53
+ Enumdate.monthly_by_day(start).lazy.map(&:to_s).take(3).force
54
+ # => ["2021-06-01", "2021-07-06", "2021-08-03"]
55
+
56
+ # every Tuesday:
57
+ Enumdate.weekly(start).lazy.map(&:to_s).take(3).force
58
+ # => ["2021-06-01", "2021-06-08", "2021-06-15"]
59
+
60
+ # Everyday:
61
+ Enumdate.daily(start).lazy.map(&:to_s).take(3).force
62
+ # => ["2021-06-01", "2021-06-02", "2021-06-03"]
63
+ #+end_src
64
+
65
+ These constructor methods can take more complex parameters
66
+ such as ~month:~, ~mday:~, ~wday:~, ~nth:~, ~wkst:~, ~interval:~.
67
+ See the gem document for details.
68
+
69
+ *** Make finit durations
70
+ This code makes infinit loop (every two years forever):
71
+ #+begin_src ruby
72
+ start = Date.new(2021, 6, 1)
73
+ Enumdate.yearly_by_monthday(start, interval: 2).each do |date|
74
+ puts date
75
+ end
76
+ #+end_src
77
+ Results:
78
+ : 2021-06-01
79
+ : 2023-06-01
80
+ : 2025-06-01
81
+ : :
82
+
83
+ To clip the duration, you can use ~forward_to~ and ~until~:
84
+ #+begin_src ruby
85
+ start = Date.new(2021, 6, 1)
86
+ Enumdate.yearly_by_monthday(start, interval: 2)
87
+ .forward_to(Date.new(2022, 1, 1))
88
+ .until(Date.new(2025, 12, 31))
89
+ .each do |date|
90
+ puts date
91
+ end
92
+ #+end_src
93
+ Rssults:
94
+ : 2023-06-01
95
+ : 2025-06-01
96
+
97
+ Note that the meaning of ~forward_to~ is different from that of
98
+ changing the ~start~ parameter.
99
+ #+begin_src ruby
100
+ start = Date.new(2022, 1, 1) # changed as if set forward_to
101
+ Enumdate.yearly_by_monthday(start, interval: 2)
102
+ .until(Date.new(2025, 12, 31))
103
+ .each do |date|
104
+ puts date
105
+ end
106
+ #+end_src
107
+ Rssults:
108
+ : 2022-01-01
109
+ : 2024-01-01
110
+
111
+ ~forward_to~ and ~until~ clip concrete occurrences without changing
112
+ the recurring pattern.
113
+
114
+ *** Merge multiple enumerables
115
+ Sometimes, you may need to compose more complex recurring patterns.
116
+ In this case, you can merge multiple enumerables:
117
+ #+begin_src ruby
118
+ mon = Date.new(2021, 8, 2) # = 1st Monday
119
+ wed = Date.new(2021, 8, 4) # = 1st Wednesday
120
+
121
+ # Every Monday and Wednesday:
122
+ (Enumdate::EnumMerger.new << Enumdate.weekly(mon) << Enumdate.weekly(wed))
123
+ .lazy.map(&:to_s).take(4).force
124
+ # => ["2021-08-02", "2021-08-04", "2021-08-09", "2021-08-11"]
125
+ #+end_src
126
+
127
+ ** Installation
128
+ Add this line to your application's Gemfile:
129
+ #+begin_src ruby
130
+ gem "enumdate"
131
+ #+end_src
132
+
133
+ And then execute:
134
+ #+begin_src shell-script
135
+ $ bundle install
136
+ #+end_src
137
+
138
+ Or install it yourself as:
139
+ #+begin_src shell-script
140
+ $ gem install enumdate
141
+ #+end_src
142
+
143
+ ** Contributing
144
+ Bug reports and pull requests are welcome on GitHub at https://github.com/yoshinari-nomura/enumdate.
145
+
146
+ ** License
147
+ The gem is available as open source under the terms of the [[https://opensource.org/licenses/MIT][MIT License]].
data/Rakefile ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+ require "rubocop/rake_task"
6
+ require "yard"
7
+
8
+ Rake::TestTask.new(:test) do |t|
9
+ t.libs << "test"
10
+ t.libs << "lib"
11
+ t.test_files = FileList["test/**/*_test.rb"]
12
+ end
13
+
14
+ RuboCop::RakeTask.new
15
+
16
+ YARD::Rake::YardocTask.new do |t|
17
+ t.options = ["-m", "org"]
18
+ end
19
+
20
+ task doc: %i[yard]
21
+ task default: %i[test rubocop]
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "enumdate"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require "irb"
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/enumdate.gemspec ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/enumdate/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "enumdate"
7
+ spec.version = Enumdate::VERSION
8
+ spec.authors = ["Yoshinari Nomura"]
9
+ spec.email = ["nom@quickhack.net"]
10
+
11
+ spec.summary = "Enumerator for recurring dates."
12
+ spec.homepage = "https://github.com/yoshinari-nomura/enumdate"
13
+ spec.license = "MIT"
14
+ spec.required_ruby_version = ">= 2.5.0"
15
+
16
+ spec.metadata["homepage_uri"] = spec.homepage
17
+ spec.metadata["source_code_uri"] = "https://github.com/yoshinari-nomura/enumdate"
18
+ # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
19
+
20
+ # Specify which files should be added to the gem when it is released.
21
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
22
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
23
+ `git ls-files -z`.split("\x0").reject {|f| f.match(%r{\A(?:test|spec|features)/}) }
24
+ end
25
+ spec.bindir = "exe"
26
+ spec.executables = spec.files.grep(%r{\Aexe/}) {|f| File.basename(f) }
27
+ spec.require_paths = ["lib"]
28
+ end
data/lib/enumdate.rb ADDED
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Enumerator for recurring dates
4
+ module Enumdate
5
+ class Error < StandardError; end
6
+
7
+ require "date"
8
+
9
+ dir = File.expand_path("enumdate", File.dirname(__FILE__))
10
+
11
+ autoload :EnumMerger, "#{dir}/enum_merger.rb"
12
+ autoload :DateEnumerator, "#{dir}/date_enumerator.rb"
13
+ autoload :DateFrame, "#{dir}/date_frame.rb"
14
+ autoload :DateHelper, "#{dir}/date_helper.rb"
15
+ autoload :VERSION, "#{dir}/version.rb"
16
+
17
+ class << self
18
+ # @return [DateEnumerator::YearlyByMonthday]
19
+ def yearly_by_monthday(first_date, month: nil, mday: nil, interval: 1)
20
+ month ||= first_date.month
21
+ mday ||= first_date.mday
22
+ DateEnumerator::YearlyByMonthday.new(
23
+ first_date: first_date,
24
+ month: month,
25
+ mday: mday,
26
+ interval: interval
27
+ )
28
+ end
29
+
30
+ # @return [DateEnumerator::YearlyByDay]
31
+ def yearly_by_day(first_date, month: nil, nth: nil, wday: nil, interval: 1)
32
+ month ||= first_date.month
33
+ nth ||= (first_date.mday + 6) / 7
34
+ wday ||= first_date.wday
35
+ DateEnumerator::YearlyByDay.new(
36
+ first_date: first_date,
37
+ month: month,
38
+ nth: nth,
39
+ wday: wday,
40
+ interval: interval
41
+ )
42
+ end
43
+
44
+ # @return [DateEnumerator::MonthlyByMonthday]
45
+ def monthly_by_monthday(first_date, mday: nil, interval: 1)
46
+ mday ||= first_date.mday
47
+ DateEnumerator::MonthlyByMonthday.new(
48
+ first_date: first_date,
49
+ mday: mday,
50
+ interval: interval
51
+ )
52
+ end
53
+
54
+ # @return [DateEnumerator::MonthlyByDay]
55
+ def monthly_by_day(first_date, nth: nil, wday: nil, interval: 1)
56
+ nth ||= (first_date.mday + 6) / 7
57
+ wday ||= first_date.wday
58
+ DateEnumerator::MonthlyByDay.new(
59
+ first_date: first_date,
60
+ nth: nth,
61
+ wday: wday,
62
+ interval: interval
63
+ )
64
+ end
65
+
66
+ # @return [DateEnumerator::Weekly]
67
+ def weekly(first_date, wday: nil, wkst: 1, interval: 1)
68
+ wday ||= first_date.wday
69
+ DateEnumerator::Weekly.new(
70
+ first_date: first_date,
71
+ wday: wday,
72
+ wkst: wkst,
73
+ interval: interval
74
+ )
75
+ end
76
+
77
+ # @return [DateEnumerator::Daily]
78
+ def daily(first_date, interval: 1)
79
+ DateEnumerator::Daily.new(
80
+ first_date: first_date,
81
+ interval: interval
82
+ )
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumdate
4
+ module DateEnumerator
5
+ # Base class for DateEnumerator
6
+ class Base
7
+ include DateHelper
8
+ include Enumerable
9
+
10
+ def initialize(first_date:, interval: 1, wkst: 1)
11
+ @first_date, @interval, @wkst = first_date, interval, wkst
12
+ @frame_manager = frame_manager.new(first_date, interval, wkst)
13
+ @duration_begin = first_date
14
+ @duration_until = nil
15
+ end
16
+
17
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
18
+ def each
19
+ return enum_for(:each) unless block_given?
20
+
21
+ # ~first_date~ value always counts as the first occurrence
22
+ # even if it does not match the specified rule.
23
+ # cf. DTSTART vs RRULE in RFC5445.
24
+ yield @first_date if between_duration?(@first_date)
25
+
26
+ @frame_manager.each do |frame|
27
+ # Avoid inifinit loops even if the rule emits no occurrences
28
+ # such as "31st April in every year".
29
+ # (Every ~occurrence_in_frame(frame)~ returns nil)
30
+ break if @duration_until && @duration_until < frame
31
+
32
+ # In some cases, ~occurrence_in_frame~ returns nil.
33
+ # For example, a recurrence that returns 31st of each month
34
+ # will return nil for short months such as April and June.
35
+ next unless (date = occurrence_in_frame(frame))
36
+
37
+ break if @duration_until && @duration_until < date
38
+
39
+ # ~occurrence_in_frame~ may return a date earlier than
40
+ # ~first_date~ in the first iteration. This is because
41
+ # ~first_date~ does not necessarily follow the repetetion
42
+ # rule. For example, if the rule is "every August 1st" and
43
+ # ~first_date~ is August 15th, The first occurrence calculated
44
+ # by the rule returns "August 1st", which is earlier than
45
+ # August 15th. In this context, ~@duration_begin~ is the matter.
46
+ next if date < @duration_begin
47
+
48
+ # Ignore ~first_date~ not to yield twice.
49
+ next if date == @first_date
50
+
51
+ yield date
52
+ end
53
+ end
54
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
55
+
56
+ # Set the new beginning of duration for the recurrence rule.
57
+ #
58
+ # It also controls the underlying frame manager. Since the
59
+ # frame manager is enough smart to avoid unnecessary repetition,
60
+ # there is no problem in setting the date hundred years later.
61
+ #
62
+ # Note that the meaning of calling ~forward_to~ is different
63
+ # from that of setting the ~first_date~ parameter on creation.
64
+ # For example, if a yearly event has *two-years* ~interval~:
65
+ #
66
+ # 1) if first_date is 2021-08-01 and forward_to 2022-08-01,
67
+ # it will create [2021-08-01 2023-08-01 ...]
68
+ #
69
+ # 2) if first_date is 2022-08-01,
70
+ # it will create [2022-08-01 2024-08-01 ...]
71
+ #
72
+ def forward_to(date)
73
+ @frame_manager.forward_to(date)
74
+ @duration_begin = date
75
+ self
76
+ end
77
+
78
+ # Imprement rewind for Enumrator class
79
+ def rewind
80
+ @frame_manager.rewind
81
+ self
82
+ end
83
+
84
+ # Set the new end of duration for the recurrence rule.
85
+ def until(date)
86
+ @duration_until = date
87
+ self
88
+ end
89
+
90
+ private
91
+
92
+ def between_duration?(date)
93
+ (!@duration_begin || @duration_begin <= date) &&
94
+ (!@duration_until || date <= @duration_until)
95
+ end
96
+
97
+ def frame_manager
98
+ raise NotImplementedError
99
+ end
100
+
101
+ def occurrence_in_frame(date)
102
+ raise NotImplementedError
103
+ end
104
+ end
105
+
106
+ ################################################################
107
+ # Enumerate yealy dates by day like: Apr 4th Tue
108
+ class YearlyByDay < Base
109
+ def initialize(first_date:, month:, nth:, wday:, interval: 1)
110
+ super(first_date: first_date, interval: interval)
111
+ @month, @nth, @wday = month, nth, wday
112
+ end
113
+
114
+ private
115
+
116
+ def frame_manager
117
+ DateFrame::Yearly
118
+ end
119
+
120
+ def occurrence_in_frame(date)
121
+ make_date_by_day(year: date.year, month: @month, nth: @nth, wday: @wday)
122
+ rescue ArgumentError
123
+ nil
124
+ end
125
+ end
126
+
127
+ ################################################################
128
+ # Enumerate yealy dates by month-day like: Apr 22
129
+ # s, e = Date.new(2021, 1, 1), Date.new(20100, 12, 31)
130
+ # Enumdate::YearlyByMonthday(start_date: s, end_date: e, month: 4, mday: 22, interval: 2).map(&:to_s)
131
+ # : => [2021-04-22, 2023-04-22, ..., 2099-04-22]
132
+ class YearlyByMonthday < Base
133
+ def initialize(first_date:, month:, mday:, interval: 1)
134
+ super(first_date: first_date, interval: interval)
135
+ @month, @mday = month, mday
136
+ end
137
+
138
+ private
139
+
140
+ def frame_manager
141
+ DateFrame::Yearly
142
+ end
143
+
144
+ def occurrence_in_frame(date)
145
+ Date.new(date.year, @month, @mday)
146
+ rescue Date::Error
147
+ nil
148
+ end
149
+ end
150
+
151
+ ################################################################
152
+ # Enumerate monthly dates by day like: 4th Tue
153
+ class MonthlyByDay < Base
154
+ def initialize(first_date:, nth:, wday:, interval: 1)
155
+ super(first_date: first_date, interval: interval)
156
+ @nth, @wday = nth, wday
157
+ end
158
+
159
+ private
160
+
161
+ def frame_manager
162
+ DateFrame::Monthly
163
+ end
164
+
165
+ def occurrence_in_frame(date)
166
+ make_date_by_day(year: date.year, month: date.month, nth: @nth, wday: @wday)
167
+ rescue Date::Error
168
+ nil
169
+ end
170
+ end
171
+
172
+ ################################################################
173
+ # Enumerate monthly dates by month-day like: 22
174
+ class MonthlyByMonthday < Base
175
+ def initialize(first_date:, mday:, interval: 1)
176
+ super(first_date: first_date, interval: interval)
177
+ @mday = mday
178
+ end
179
+
180
+ private
181
+
182
+ def frame_manager
183
+ DateFrame::Monthly
184
+ end
185
+
186
+ def occurrence_in_frame(date)
187
+ Date.new(date.year, date.month, @mday)
188
+ rescue Date::Error
189
+ nil
190
+ end
191
+ end
192
+
193
+ ################################################################
194
+ # Enumerate weekly dates like: Tue
195
+ class Weekly < Base
196
+ def initialize(first_date:, wday:, interval: 1, wkst: 1)
197
+ super(first_date: first_date, interval: interval, wkst: wkst)
198
+ @wday = wday
199
+ end
200
+
201
+ private
202
+
203
+ def frame_manager
204
+ DateFrame::Weekly
205
+ end
206
+
207
+ # Sun Mon Tue Wed Thu Fri Sat Sun Mon Tue ...
208
+ # 0 1 2 3 4 5 6 0 1 2 ...
209
+ def occurrence_in_frame(date)
210
+ bof = date - ((date.wday - @wkst) % 7)
211
+ candidate = bof + (@wday - bof.wday) % 7
212
+ return candidate if date <= candidate
213
+
214
+ nil
215
+ end
216
+ end
217
+
218
+ ################################################################
219
+ # Enumerate every n days
220
+ class Daily < Base
221
+ def initialize(first_date:, interval: 1)
222
+ super(first_date: first_date, interval: interval)
223
+ end
224
+
225
+ private
226
+
227
+ def frame_manager
228
+ DateFrame::Daily
229
+ end
230
+
231
+ def occurrence_in_frame(date)
232
+ date
233
+ end
234
+ end
235
+
236
+ ################################################################
237
+ # Enumerate dates from list.
238
+ class ByDateList
239
+ include Enumerable
240
+
241
+ def initialize(date_list: [])
242
+ @date_list = date_list
243
+ @duration_until = nil
244
+ end
245
+
246
+ def <<(date)
247
+ @date_list << date
248
+ end
249
+
250
+ def rewind; end
251
+
252
+ def until(date)
253
+ @duration_until = date
254
+ end
255
+
256
+ def forward_to(date)
257
+ @first_date = date
258
+ end
259
+
260
+ def each
261
+ return enum_for(:each) unless block_given?
262
+
263
+ @date_list.sort.each do |date|
264
+ next if @fist_date && date < @first_date
265
+ break if @duration_until && date > @duration_until
266
+
267
+ yield date
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumdate
4
+ # SYNOPSIS:
5
+ # + Enumdate::DateFrame::Yearly.new(first_date, interval)
6
+ # + Enumdate::DateFrame::Monthly.new(first_date, interval)
7
+ # + Enumdate::DateFrame::Weekly.new(first_date, interval, wkst)
8
+ # + Enumdate::DateFrame::Daily.new(first_date, interval)
9
+ #
10
+ # Iterate the date in frames of year, month, week, or day,
11
+ # and enumerate the first date of each frame.
12
+ #
13
+ # ~FIRST_DATE~ is an arbitrary date of the first frame.
14
+ #
15
+ # #+begin_src ruby
16
+ # Enumdate::DateFrame::Yearly.new(Date.new(2021, 1, 1), 2).lazy.map(&:to_s).take(3).force
17
+ # # => ["2021-01-01", "2023-01-01", "2025-01-01"]
18
+ #
19
+ # Enumdate::DateFrame::Yearly.new(Date.new(2021, 6, 1), 2).lazy.map(&:to_s).take(3).force
20
+ # # => ["2021-01-01", "2023-01-01", "2025-01-01"]
21
+ #
22
+ # Enumdate::DateFrame::Monthly.new(Date.new(2021, 6, 10), 2).lazy.map(&:to_s).take(3).force
23
+ # # => ["2021-06-01", "2021-08-01", "2021-10-01"]
24
+ # #+end_src
25
+ #
26
+ # ~Enumdate::DateFrame::Weekly~ is sensitive to ~WKST~ param:
27
+ #
28
+ # 2021-06-08 is Tuesday:
29
+ # #+begin_example
30
+ # June 2021
31
+ # Su Mo Tu We Th Fr Sa
32
+ # 30 31 1 2 3 4 5
33
+ # 6 7 8 9 10 11 12
34
+ # 13 14 15 16 17 18 19
35
+ # 20 21 22 23 24 25 26
36
+ # 27 28 29 30 1 2 3
37
+ # #+end_example
38
+ #
39
+ # #+begin_src ruby
40
+ # sun, mon, tue, wed = 0, 1, 2, 3
41
+ #
42
+ # # If week start is Sunday:
43
+ # Enumdate::DateFrame::Weekly.new(Date.new(2021, 6, 8), 2, sun).lazy.map(&:to_s).take(3).force
44
+ # # => ["2021-06-06", "2021-06-20", "2021-07-04"]
45
+ #
46
+ # # Monday (default):
47
+ # Enumdate::DateFrame::Weekly.new(Date.new(2021, 6, 8), 2, mon).lazy.map(&:to_s).take(3).force
48
+ # # => ["2021-06-07", "2021-06-21", "2021-07-05"]
49
+ #
50
+ # # Tuesday:
51
+ # Enumdate::DateFrame::Weekly.new(Date.new(2021, 6, 8), 2, tue).lazy.map(&:to_s).take(3).force
52
+ # # => ["2021-06-08", "2021-06-22", "2021-07-06"]
53
+ #
54
+ # # Wednesday:
55
+ # Enumdate::DateFrame::Weekly.new(Date.new(2021, 6, 8), 2, wed).lazy.map(&:to_s).take(3).force
56
+ # # => ["2021-06-02", "2021-06-16", "2021-06-30"]
57
+ #
58
+ # Enumdate::DateFrame::Daily.new(Date.new(2021, 5, 15), 10).lazy.map(&:to_s).take(3).force
59
+ # # => ["2021-05-15", "2021-05-25", "2021-06-04"]
60
+ #
61
+ # # `forward_to` method is helpful to jump to some specific date before the iteration.
62
+ # Enumdate::DateFrame::Yearly.new(Date.new(2021, 1, 1), 2).forward_to(Date.new(2100, 1, 1))
63
+ # .lazy.map(&:to_s).take(3).force
64
+ # # => ["2101-01-01", "2103-01-01", "2105-01-01"]
65
+ #
66
+ # # Let's see how it differs from simply changing FIRST_DATE:
67
+ # Enumdate::DateFrame::Yearly.new(Date.new(2100, 1, 1), 2).lazy.map(&:to_s).take(3).force
68
+ # # => ["2100-01-01", "2102-01-01", "2104-01-01"]
69
+ # #+end_src
70
+ #
71
+ module DateFrame
72
+ # DateFrame: yearly, monthly, weekly, and daily
73
+ class Base
74
+ include DateHelper
75
+ include Enumerable
76
+
77
+ def initialize(first_date, interval = 1, wkst = 1)
78
+ @first_date, @interval, @wkst = first_date, interval, wkst
79
+ rewind
80
+ end
81
+
82
+ def each
83
+ return enum_for(:each) unless block_given?
84
+
85
+ loop do
86
+ yield @current_frame_date
87
+ @current_frame_date = next_frame_start(@current_frame_date)
88
+ end
89
+ end
90
+
91
+ # Imprement rewind for Enumrator class
92
+ def rewind
93
+ @current_frame_date = beginning_of_frame(@first_date)
94
+ self
95
+ end
96
+
97
+ # Go forward to the frame in which DATE is involved
98
+ def forward_to(date)
99
+ rewind # reset @current_frame_date
100
+ frames = frames_between(@current_frame_date, date)
101
+ cycles = (frames + (@interval - 1)) / @interval
102
+ @current_frame_date = next_frame_start(@current_frame_date, cycles) if cycles.positive?
103
+ self
104
+ end
105
+
106
+ private
107
+
108
+ def next_frame_start(current_frame_date, cycles = 1)
109
+ raise NotImplementedError
110
+ end
111
+
112
+ def beginning_of_frame(date)
113
+ raise NotImplementedError
114
+ end
115
+
116
+ def frames_between(date1, date2)
117
+ raise NotImplementedError
118
+ end
119
+ end
120
+
121
+ # Dummy date frame
122
+ class Dummy < Base
123
+ end
124
+
125
+ # Yearly date frame
126
+ class Yearly < Base
127
+ private
128
+
129
+ def next_frame_start(current_frame_date, cycles = 1)
130
+ current_frame_date >> (@interval * 12 * cycles)
131
+ end
132
+
133
+ def beginning_of_frame(date)
134
+ beginning_of_year(date)
135
+ end
136
+
137
+ def frames_between(date1, date2)
138
+ years_between(date1, date2)
139
+ end
140
+ end
141
+
142
+ # Monthly date frame
143
+ class Monthly < Base
144
+ private
145
+
146
+ def next_frame_start(current_frame_date, cycles = 1)
147
+ current_frame_date >> (@interval * cycles)
148
+ end
149
+
150
+ def beginning_of_frame(date)
151
+ beginning_of_month(date)
152
+ end
153
+
154
+ def frames_between(date1, date2)
155
+ months_between(date1, date2)
156
+ end
157
+ end
158
+
159
+ # Weekly date frame
160
+ class Weekly < Base
161
+ private
162
+
163
+ def next_frame_start(current_frame_date, cycles = 1)
164
+ current_frame_date + (@interval * 7 * cycles)
165
+ end
166
+
167
+ def beginning_of_frame(date)
168
+ beginning_of_week(date, @wkst)
169
+ end
170
+
171
+ def frames_between(date1, date2)
172
+ (beginning_of_frame(date2) - beginning_of_frame(date1)) / 7
173
+ end
174
+ end
175
+
176
+ # Daily date frame
177
+ class Daily < Base
178
+ private
179
+
180
+ def next_frame_start(current_frame_date, cycles = 1)
181
+ current_frame_date + (@interval * cycles)
182
+ end
183
+
184
+ def beginning_of_frame(date)
185
+ date
186
+ end
187
+
188
+ def frames_between(date1, date2)
189
+ date2 - date1
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumdate
4
+ # Helper for date-calculation.
5
+ module DateHelper
6
+ private
7
+
8
+ # Make a date by DAY like ``1st Wed of Nov, 1999''.
9
+ # caller must make sure:
10
+ # YEAR and MONTH must be valid
11
+ # NTH must be < 0 or > 0
12
+ # WDAY must be 0:Sun .. 6:Sat
13
+ #
14
+ # raise ArgumentError if no date matches. for example:
15
+ # no 5th Saturday exists on April 2010.
16
+ #
17
+ def make_date_by_day(year:, month:, nth:, wday:)
18
+ direction = nth.positive? ? 1 : -1
19
+
20
+ edge = Date.new(year, month, direction)
21
+ ydiff = nth - direction
22
+ xdiff = direction * ((direction * (wday - edge.wday)) % 7)
23
+ mday = edge.mday + ydiff * 7 + xdiff
24
+
25
+ raise ArgumentError if mday < 1
26
+
27
+ Date.new(year, month, mday)
28
+ end
29
+
30
+ def beginning_of_year(date)
31
+ date.class.new(date.year, 1, 1)
32
+ end
33
+
34
+ def beginning_of_month(date)
35
+ date.class.new(date.year, date.month, 1)
36
+ end
37
+
38
+ def beginning_of_week(date, wkst = 1)
39
+ date - ((date.wday - wkst) % 7)
40
+ end
41
+
42
+ def years_between(date1, date2)
43
+ date2.year - date1.year
44
+ end
45
+
46
+ def months_between(date1, date2)
47
+ (date2.year * 12 + date2.month) - (date1.year * 12 + date1.month)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumdate
4
+ # Create new Enumerator merging multiple enumerators.
5
+ #
6
+ # All enumerators should yield objects that respond to `<=>` method.
7
+ # enums = (EnumMerger.new << enum1 << enum2).to_enum
8
+ #
9
+ class EnumMerger
10
+ include Enumerable
11
+
12
+ def initialize
13
+ @enumerators = []
14
+ end
15
+
16
+ # Imprement each for Enumrator class
17
+ def each
18
+ return enum_for(:each) unless block_given?
19
+
20
+ loop do
21
+ yield next_minimum(@enumerators)
22
+ end
23
+ end
24
+
25
+ # Imprement rewind for Enumrator class
26
+ def rewind
27
+ @enumerators.map(&:rewind)
28
+ end
29
+
30
+ # Add enumerator
31
+ def <<(enumerator)
32
+ @enumerators << enumerator.to_enum
33
+ self
34
+ end
35
+
36
+ private
37
+
38
+ # Yield next minimum value
39
+ def next_minimum(enumerators)
40
+ raise StopIteration if enumerators.empty?
41
+
42
+ # Could raise StopIteration
43
+ minimum_enumrator(enumerators).next
44
+ end
45
+
46
+ def minimum_enumrator(enumerators)
47
+ min_e, min_v = enumerators.first, nil
48
+ enumerators.each do |e|
49
+ v = e.peek
50
+ min_e, min_v = e, v if min_v.nil? || v < min_v
51
+ rescue StopIteration
52
+ # do nothing
53
+ end
54
+ min_e
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumdate
4
+ VERSION = "0.1.0"
5
+ end
metadata ADDED
@@ -0,0 +1,62 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: enumdate
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yoshinari Nomura
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2021-08-22 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ - nom@quickhack.net
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - ".github/workflows/main.yml"
21
+ - ".gitignore"
22
+ - ".rubocop.yml"
23
+ - ".solargraph.yml"
24
+ - Gemfile
25
+ - LICENSE.txt
26
+ - README.org
27
+ - Rakefile
28
+ - bin/console
29
+ - bin/setup
30
+ - enumdate.gemspec
31
+ - lib/enumdate.rb
32
+ - lib/enumdate/date_enumerator.rb
33
+ - lib/enumdate/date_frame.rb
34
+ - lib/enumdate/date_helper.rb
35
+ - lib/enumdate/enum_merger.rb
36
+ - lib/enumdate/version.rb
37
+ homepage: https://github.com/yoshinari-nomura/enumdate
38
+ licenses:
39
+ - MIT
40
+ metadata:
41
+ homepage_uri: https://github.com/yoshinari-nomura/enumdate
42
+ source_code_uri: https://github.com/yoshinari-nomura/enumdate
43
+ post_install_message:
44
+ rdoc_options: []
45
+ require_paths:
46
+ - lib
47
+ required_ruby_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: 2.5.0
52
+ required_rubygems_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '0'
57
+ requirements: []
58
+ rubygems_version: 3.2.22
59
+ signing_key:
60
+ specification_version: 4
61
+ summary: Enumerator for recurring dates.
62
+ test_files: []