feedkit 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 +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE.txt +21 -0
- data/README.md +547 -0
- data/Rakefile +15 -0
- data/app/jobs/feedkit/dispatch_job.rb +31 -0
- data/app/jobs/feedkit/generate_feed_job.rb +19 -0
- data/app/models/feedkit/feed.rb +17 -0
- data/lib/feedkit/configuration.rb +15 -0
- data/lib/feedkit/engine.rb +15 -0
- data/lib/feedkit/feeds_owner.rb +16 -0
- data/lib/feedkit/generator.rb +84 -0
- data/lib/feedkit/registry.rb +52 -0
- data/lib/feedkit/schedulable.rb +29 -0
- data/lib/feedkit/schedule.rb +222 -0
- data/lib/feedkit/version.rb +5 -0
- data/lib/feedkit.rb +45 -0
- data/lib/generators/feedkit/generator_generator.rb +34 -0
- data/lib/generators/feedkit/install_generator.rb +46 -0
- data/lib/generators/feedkit/templates/generator.rb.tt +23 -0
- data/lib/generators/feedkit/templates/generator_test.rb.tt +38 -0
- data/lib/generators/feedkit/templates/initializer.rb.tt +19 -0
- data/lib/generators/feedkit/templates/migration.rb.tt +19 -0
- metadata +82 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Feedkit
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace Feedkit
|
|
6
|
+
|
|
7
|
+
config.generators do |g|
|
|
8
|
+
g.test_framework :minitest, fixture: false
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer "feedkit.set_configs" do
|
|
12
|
+
Feedkit.configuration.logger ||= Rails.logger
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module Feedkit
|
|
6
|
+
module FeedsOwner
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
has_many Feedkit.configuration.association_name,
|
|
11
|
+
class_name: "Feedkit::Feed",
|
|
12
|
+
as: :owner,
|
|
13
|
+
dependent: :delete_all
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Feedkit
|
|
4
|
+
class Generator
|
|
5
|
+
include Schedulable
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def inherited(subclass)
|
|
9
|
+
super
|
|
10
|
+
|
|
11
|
+
Feedkit::Registry.register(subclass)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def owned_by(type = nil)
|
|
15
|
+
@owner_class = type || @owner_class
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def owner_class
|
|
19
|
+
return @owner_class.constantize if @owner_class.is_a?(String)
|
|
20
|
+
|
|
21
|
+
@owner_class
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def feed_type
|
|
25
|
+
name.demodulize.underscore.to_sym
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def scheduled?
|
|
29
|
+
schedules.any?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(owner = nil, period_name: nil, **options)
|
|
34
|
+
@owner = owner
|
|
35
|
+
@options = options
|
|
36
|
+
@schedule = period_name ? self.class.find_schedule(period_name) : nil
|
|
37
|
+
|
|
38
|
+
raise ArgumentError, "Unknown schedule: #{period_name}" if period_name && !@schedule
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call
|
|
42
|
+
return if already_generated?
|
|
43
|
+
return unless (payload = data)
|
|
44
|
+
|
|
45
|
+
create_feed!(payload)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
attr_reader :owner, :options
|
|
51
|
+
|
|
52
|
+
def data
|
|
53
|
+
raise NotImplementedError, "#{self.class.name} must implement #data"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def period
|
|
57
|
+
@schedule&.period
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def period_name
|
|
61
|
+
@schedule&.period_name
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def already_generated?
|
|
65
|
+
return false unless @schedule
|
|
66
|
+
return false unless @owner
|
|
67
|
+
|
|
68
|
+
feed_scope.where(feed_type: self.class.feed_type, period_name: period_name)
|
|
69
|
+
.exists?(["created_at > ?", period.ago])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def create_feed!(payload)
|
|
73
|
+
if @owner
|
|
74
|
+
feed_scope.create!(feed_type: self.class.feed_type, period_name: period_name, data: payload)
|
|
75
|
+
else
|
|
76
|
+
Feedkit::Feed.create!(feed_type: self.class.feed_type, period_name: period_name, data: payload)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def feed_scope
|
|
81
|
+
@owner.public_send(Feedkit.configuration.association_name)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Feedkit
|
|
4
|
+
# Tracks all generator classes via the inherited hook in Feedkit::Generator.
|
|
5
|
+
# Registration happens at class load time (boot), not during requests,
|
|
6
|
+
# so thread-safety of the underlying Set is not a concern in practice.
|
|
7
|
+
module Registry
|
|
8
|
+
class << self
|
|
9
|
+
def generators
|
|
10
|
+
@generators ||= Set.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def register(klass)
|
|
14
|
+
generators << klass
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def unregister(klass)
|
|
18
|
+
generators.delete(klass)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def clear!
|
|
22
|
+
@generators = Set.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns generators that have schedules defined.
|
|
26
|
+
def scheduled_generators
|
|
27
|
+
generators.select(&:scheduled?)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns scheduled generators that also have an owner class.
|
|
31
|
+
# Only these can be dispatched automatically, since DispatchJob
|
|
32
|
+
# iterates over owner records to enqueue GenerateFeedJob.
|
|
33
|
+
def dispatchable_generators
|
|
34
|
+
scheduled_generators.select(&:owner_class)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def generators_for_owner(owner_class)
|
|
38
|
+
generators.select { |g| g.owner_class == owner_class }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def due_at(time = Time.current)
|
|
42
|
+
Feedkit.eager_load_generators!
|
|
43
|
+
|
|
44
|
+
dispatchable_generators.flat_map do |generator|
|
|
45
|
+
generator.schedules_due(time).map do |schedule|
|
|
46
|
+
{ generator: generator, period_name: schedule.period_name }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module Feedkit
|
|
6
|
+
module Schedulable
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
class_methods do
|
|
10
|
+
def schedules
|
|
11
|
+
@schedules ||= []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def schedule(every:, at:, as: nil, superseded_by: [])
|
|
15
|
+
schedules << Feedkit::Schedule.new(every: every, at: at, as: as, superseded_by: superseded_by)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def schedules_due(time = Time.current)
|
|
19
|
+
due = schedules.select { |s| s.due?(time) }
|
|
20
|
+
due_names = due.map(&:period_name)
|
|
21
|
+
due.reject { |s| s.superseded_by.any? { |name| due_names.include?(name) } }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def find_schedule(period_name)
|
|
25
|
+
schedules.find { |s| s.period_name == period_name&.to_s }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Feedkit
|
|
4
|
+
class Schedule # rubocop:disable Metrics/ClassLength
|
|
5
|
+
VALID_CONDITION_TYPES = %i[hour day weekday week month].freeze
|
|
6
|
+
VALID_HOUR_RANGE = (0..23)
|
|
7
|
+
VALID_DAY_RANGE = (1..31)
|
|
8
|
+
VALID_WEEKDAY_RANGE = (0..6)
|
|
9
|
+
VALID_WEEK_RANGE = (1..53)
|
|
10
|
+
VALID_MONTH_RANGE = (1..12)
|
|
11
|
+
SYMBOLIC_DAY_VALUES = %i[first last].freeze
|
|
12
|
+
SYMBOLIC_WEEK_VALUES = %i[even odd].freeze
|
|
13
|
+
|
|
14
|
+
WEEKDAYS = {
|
|
15
|
+
sunday: 0,
|
|
16
|
+
monday: 1,
|
|
17
|
+
tuesday: 2,
|
|
18
|
+
wednesday: 3,
|
|
19
|
+
thursday: 4,
|
|
20
|
+
friday: 5,
|
|
21
|
+
saturday: 6
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
MONTHS = {
|
|
25
|
+
january: 1,
|
|
26
|
+
february: 2,
|
|
27
|
+
march: 3,
|
|
28
|
+
april: 4,
|
|
29
|
+
may: 5,
|
|
30
|
+
june: 6,
|
|
31
|
+
july: 7,
|
|
32
|
+
august: 8,
|
|
33
|
+
september: 9,
|
|
34
|
+
october: 10,
|
|
35
|
+
november: 11,
|
|
36
|
+
december: 12
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
CONDITION_ABBREVIATIONS = {
|
|
40
|
+
hour: "h",
|
|
41
|
+
day: "d",
|
|
42
|
+
weekday: "wd",
|
|
43
|
+
week: "w",
|
|
44
|
+
month: "m"
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
attr_reader :period_name, :period, :conditions, :superseded_by
|
|
48
|
+
|
|
49
|
+
def initialize(every:, at:, as: nil, superseded_by: [])
|
|
50
|
+
@period = every
|
|
51
|
+
@conditions = at
|
|
52
|
+
@superseded_by = Array(superseded_by).map(&:to_s)
|
|
53
|
+
|
|
54
|
+
validate_conditions!
|
|
55
|
+
|
|
56
|
+
@period_name = (as || generate_period_name).to_s
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def due?(time = Time.current)
|
|
60
|
+
conditions.all? { |type, value| matches?(type, value, time) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def validate_conditions!
|
|
66
|
+
raise ArgumentError, "conditions must be a Hash" unless conditions.is_a?(Hash)
|
|
67
|
+
|
|
68
|
+
conditions.each { |type, value| validate_condition!(type, value) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def validate_condition!(type, value)
|
|
72
|
+
unless VALID_CONDITION_TYPES.include?(type)
|
|
73
|
+
raise ArgumentError, "Unknown condition type: #{type}. Valid types: #{VALID_CONDITION_TYPES.join(", ")}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
validate_condition_value!(type, value)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_condition_value!(type, value)
|
|
80
|
+
case value
|
|
81
|
+
when Range then validate_range_value!(type, value)
|
|
82
|
+
when Array then value.each { |v| validate_scalar_value!(type, v) }
|
|
83
|
+
else validate_scalar_value!(type, value)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def validate_range_value!(type, range)
|
|
88
|
+
validate_scalar_value!(type, range.begin)
|
|
89
|
+
validate_scalar_value!(type, range.end)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_scalar_value!(type, value)
|
|
93
|
+
case type
|
|
94
|
+
when :hour then validate_hour_value!(value)
|
|
95
|
+
when :day then validate_day_value!(value)
|
|
96
|
+
when :weekday then validate_weekday_value!(value)
|
|
97
|
+
when :week then validate_week_value!(value)
|
|
98
|
+
when :month then validate_month_value!(value)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate_hour_value!(value)
|
|
103
|
+
return if value.is_a?(Integer) && VALID_HOUR_RANGE.cover?(value)
|
|
104
|
+
|
|
105
|
+
raise ArgumentError, "Invalid hour value: #{value}. Must be integer 0-23"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def validate_day_value!(value)
|
|
109
|
+
return if SYMBOLIC_DAY_VALUES.include?(value)
|
|
110
|
+
return if value.is_a?(Integer) && VALID_DAY_RANGE.cover?(value)
|
|
111
|
+
|
|
112
|
+
raise ArgumentError, "Invalid day value: #{value}. Must be integer 1-31 or :first/:last"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validate_weekday_value!(value)
|
|
116
|
+
return if WEEKDAYS.key?(value)
|
|
117
|
+
return if value.is_a?(Integer) && VALID_WEEKDAY_RANGE.cover?(value)
|
|
118
|
+
|
|
119
|
+
raise ArgumentError,
|
|
120
|
+
"Invalid weekday value: #{value}. Must be integer 0-6 or symbol (#{WEEKDAYS.keys.join(", ")})"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def validate_week_value!(value)
|
|
124
|
+
return if SYMBOLIC_WEEK_VALUES.include?(value)
|
|
125
|
+
return if value.is_a?(Integer) && VALID_WEEK_RANGE.cover?(value)
|
|
126
|
+
|
|
127
|
+
raise ArgumentError, "Invalid week value: #{value}. Must be integer 1-53 or :even/:odd"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def validate_month_value!(value)
|
|
131
|
+
return if MONTHS.key?(value)
|
|
132
|
+
return if value.is_a?(Integer) && VALID_MONTH_RANGE.cover?(value)
|
|
133
|
+
|
|
134
|
+
raise ArgumentError, "Invalid month value: #{value}. Must be integer 1-12 or symbol (#{MONTHS.keys.join(", ")})"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def matches?(type, value, time)
|
|
138
|
+
actual = actual_value_for(type, time)
|
|
139
|
+
value = normalize_weekday(value) if type == :weekday
|
|
140
|
+
value = normalize_month(value) if type == :month
|
|
141
|
+
|
|
142
|
+
case value
|
|
143
|
+
when Range then value.cover?(actual)
|
|
144
|
+
when Array then value.include?(actual)
|
|
145
|
+
when Symbol then symbolic_match?(value, type, time)
|
|
146
|
+
else value == actual
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def actual_value_for(type, time)
|
|
151
|
+
case type
|
|
152
|
+
when :hour then time.hour
|
|
153
|
+
when :day then time.day
|
|
154
|
+
when :weekday then time.wday
|
|
155
|
+
when :week then time.to_date.cweek
|
|
156
|
+
when :month then time.month
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def symbolic_match?(value, type, time)
|
|
161
|
+
case [type, value]
|
|
162
|
+
when %i[day last] then time.day == time.end_of_month.day
|
|
163
|
+
when %i[day first] then time.day == 1
|
|
164
|
+
when %i[week even] then time.to_date.cweek.even?
|
|
165
|
+
when %i[week odd] then time.to_date.cweek.odd?
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def generate_period_name
|
|
170
|
+
parts = [period_abbreviation]
|
|
171
|
+
conditions.each do |type, value|
|
|
172
|
+
parts << "#{condition_abbreviation(type)}#{condition_value(type, value)}"
|
|
173
|
+
end
|
|
174
|
+
parts.join("_")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def period_abbreviation
|
|
178
|
+
case period
|
|
179
|
+
when 1.hour then "h1"
|
|
180
|
+
when 1.day then "d1"
|
|
181
|
+
when 1.week then "w1"
|
|
182
|
+
when 2.weeks then "w2"
|
|
183
|
+
when 1.month then "m1"
|
|
184
|
+
when 1.year then "y1"
|
|
185
|
+
else "s#{period.to_i}"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def condition_abbreviation(type)
|
|
190
|
+
CONDITION_ABBREVIATIONS[type]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def condition_value(type, value)
|
|
194
|
+
value = normalize_weekday(value) if type == :weekday
|
|
195
|
+
value = normalize_month(value) if type == :month
|
|
196
|
+
|
|
197
|
+
case value
|
|
198
|
+
when Range then "#{value.begin}-#{value.end}"
|
|
199
|
+
when Array then value.join("-")
|
|
200
|
+
else value
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def normalize_weekday(value)
|
|
205
|
+
case value
|
|
206
|
+
when Symbol then WEEKDAYS.fetch(value, value)
|
|
207
|
+
when Range then normalize_weekday(value.begin)..normalize_weekday(value.end)
|
|
208
|
+
when Array then value.map { |v| normalize_weekday(v) }
|
|
209
|
+
else value
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def normalize_month(value)
|
|
214
|
+
case value
|
|
215
|
+
when Symbol then MONTHS.fetch(value, value)
|
|
216
|
+
when Range then normalize_month(value.begin)..normalize_month(value.end)
|
|
217
|
+
when Array then value.map { |v| normalize_month(v) }
|
|
218
|
+
else value
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
data/lib/feedkit.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
|
|
5
|
+
require_relative "feedkit/version"
|
|
6
|
+
require_relative "feedkit/configuration"
|
|
7
|
+
require_relative "feedkit/schedule"
|
|
8
|
+
require_relative "feedkit/schedulable"
|
|
9
|
+
require_relative "feedkit/registry"
|
|
10
|
+
require_relative "feedkit/generator"
|
|
11
|
+
require_relative "feedkit/feeds_owner"
|
|
12
|
+
|
|
13
|
+
module Feedkit
|
|
14
|
+
class << self
|
|
15
|
+
def configuration
|
|
16
|
+
@configuration ||= Configuration.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def configure
|
|
20
|
+
yield(configuration)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def logger
|
|
24
|
+
configuration.logger || (defined?(Rails) ? Rails.logger : Logger.new($stdout))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def eager_load_generators!
|
|
28
|
+
return if @generators_loaded
|
|
29
|
+
return if defined?(Rails) && Rails.application.config.eager_load
|
|
30
|
+
|
|
31
|
+
configuration.generator_paths.each do |path|
|
|
32
|
+
pattern = defined?(Rails) ? Rails.root.join(path) : path
|
|
33
|
+
Dir[pattern].each { |file| require file }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
@generators_loaded = true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def reset_eager_load!
|
|
40
|
+
@generators_loaded = false
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
require_relative "feedkit/engine" if defined?(Rails::Engine)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Feedkit
|
|
6
|
+
module Generators
|
|
7
|
+
class GeneratorGenerator < Rails::Generators::NamedBase
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
class_option :owner,
|
|
11
|
+
type: :string,
|
|
12
|
+
default: nil,
|
|
13
|
+
desc: "The owner model class name (e.g., Organization)"
|
|
14
|
+
|
|
15
|
+
def create_generator_file
|
|
16
|
+
template "generator.rb.tt", "app/generators/#{file_name}.rb"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create_test_file
|
|
20
|
+
template "generator_test.rb.tt", "test/generators/#{file_name}_test.rb"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def owner_class
|
|
26
|
+
options[:owner]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def owner?
|
|
30
|
+
owner_class.present?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/migration"
|
|
5
|
+
|
|
6
|
+
module Feedkit
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include Rails::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
class_option :owner_id_type,
|
|
14
|
+
type: :string,
|
|
15
|
+
default: "bigint",
|
|
16
|
+
desc: "The type for owner_id column (bigint or uuid)"
|
|
17
|
+
|
|
18
|
+
def self.next_migration_number(dirname)
|
|
19
|
+
if ActiveRecord::Base.timestamped_migrations
|
|
20
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
21
|
+
else
|
|
22
|
+
format("%<number>.3d", number: (current_migration_number(dirname) + 1))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def create_initializer
|
|
27
|
+
template "initializer.rb.tt", "config/initializers/feedkit.rb"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def create_migration
|
|
31
|
+
migration_template "migration.rb.tt", "db/migrate/create_feedkit_feeds.rb"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def create_generators_directory
|
|
35
|
+
empty_directory "app/generators"
|
|
36
|
+
create_file "app/generators/.keep"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def owner_id_type
|
|
42
|
+
options[:owner_id_type]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= class_name %> < Feedkit::Generator
|
|
4
|
+
<% if owner? -%>
|
|
5
|
+
owned_by <%= owner_class %>
|
|
6
|
+
|
|
7
|
+
<% end -%>
|
|
8
|
+
# Define schedules for automatic feed generation
|
|
9
|
+
# schedule every: 1.day, at: { hour: 6 }, as: :daily
|
|
10
|
+
# schedule every: 1.week, at: { hour: 7, weekday: :monday }, as: :weekly
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def data
|
|
15
|
+
# Return a hash with the feed data, or nil to skip feed creation
|
|
16
|
+
# Example:
|
|
17
|
+
# {
|
|
18
|
+
# total_count: calculate_total,
|
|
19
|
+
# items: fetch_items
|
|
20
|
+
# }
|
|
21
|
+
raise NotImplementedError, '<%= class_name %> must implement #data'
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
class <%= class_name %>Test < ActiveSupport::TestCase
|
|
6
|
+
<% if owner? -%>
|
|
7
|
+
# setup do
|
|
8
|
+
# @owner = <%= owner_class.underscore.pluralize %>(:one)
|
|
9
|
+
# end
|
|
10
|
+
|
|
11
|
+
<% end -%>
|
|
12
|
+
test 'owner_class returns the configured owner type' do
|
|
13
|
+
<% if owner? -%>
|
|
14
|
+
assert_equal <%= owner_class %>, <%= class_name %>.owner_class
|
|
15
|
+
<% else -%>
|
|
16
|
+
assert_nil <%= class_name %>.owner_class
|
|
17
|
+
<% end -%>
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
test 'feed_type returns underscored class name' do
|
|
21
|
+
assert_equal :<%= file_name %>, <%= class_name %>.feed_type
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# test 'call creates a feed with expected data' do
|
|
25
|
+
<% if owner? -%>
|
|
26
|
+
# generator = <%= class_name %>.new(@owner, period_name: :daily)
|
|
27
|
+
<% else -%>
|
|
28
|
+
# generator = <%= class_name %>.new(nil)
|
|
29
|
+
<% end -%>
|
|
30
|
+
#
|
|
31
|
+
# assert_difference 'Feedkit::Feed.count', 1 do
|
|
32
|
+
# generator.call
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# feed = Feedkit::Feed.last
|
|
36
|
+
# assert_equal '<%= file_name %>', feed.feed_type
|
|
37
|
+
# end
|
|
38
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Feedkit.configure do |config|
|
|
4
|
+
# Table name for feeds (default: 'feedkit_feeds')
|
|
5
|
+
# config.table_name = 'feedkit_feeds'
|
|
6
|
+
|
|
7
|
+
# Association name for the feeds relationship (default: :feeds)
|
|
8
|
+
# config.association_name = :feeds
|
|
9
|
+
|
|
10
|
+
# Paths to search for generator classes (default: ['app/generators/**/*.rb'])
|
|
11
|
+
# config.generator_paths = ['app/generators/**/*.rb']
|
|
12
|
+
|
|
13
|
+
# Primary key type for owner_id (default: :bigint)
|
|
14
|
+
# Set to :uuid if your models use UUID primary keys
|
|
15
|
+
config.owner_id_type = :<%= owner_id_type %>
|
|
16
|
+
|
|
17
|
+
# Logger for Feedkit (defaults to Rails.logger)
|
|
18
|
+
# config.logger = Rails.logger
|
|
19
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateFeedkitFeeds < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
|
+
def change
|
|
5
|
+
create_table :feedkit_feeds do |t|
|
|
6
|
+
t.string :owner_type
|
|
7
|
+
t.<%= owner_id_type %> :owner_id
|
|
8
|
+
t.string :feed_type, null: false
|
|
9
|
+
t.string :period_name
|
|
10
|
+
t.jsonb :data, null: false, default: {}
|
|
11
|
+
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
add_index :feedkit_feeds, :created_at
|
|
16
|
+
add_index :feedkit_feeds, %i[owner_type owner_id feed_type created_at], name: 'idx_feedkit_feeds_lookup'
|
|
17
|
+
add_index :feedkit_feeds, %i[owner_type owner_id feed_type period_name], name: 'idx_feedkit_feeds_dedup'
|
|
18
|
+
end
|
|
19
|
+
end
|