compostr 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # Compostr is heavily WIP!
2
+
3
+ # Compostr
4
+
5
+ Compostr is extracted code from the [`wp_event` gem](https://github.com/ecovillage/wp_event), a solution to feed a specific wordpress instance with specific Custom Post Type instances.
6
+
7
+ Its a heavy WIP.
8
+
9
+ Compostr is a somewhat weirdly engineered wrapper to decorate ruby classes such that they can be pushed to (or fetched from) a wordpress installation that defines corresponing CPTs (Custom Post Types).
10
+
11
+ It would be fun to discuss on `Compostr`s development history and design decisions, but unfortunately that is out of scope for the time being.
12
+
13
+ Licensed under the GPLv3+, Copyright 2016, 2017 Felix Wolfsteller.
14
+
15
+ ## Installation
16
+
17
+ Add this line to your application's Gemfile:
18
+
19
+ ```ruby
20
+ gem 'compostr'
21
+ ```
22
+
23
+ And then execute:
24
+
25
+ $ bundle
26
+
27
+ Or install it yourself as:
28
+
29
+ $ gem install compostr
30
+
31
+ ## Usage
32
+
33
+ ### In a nutshell
34
+
35
+ Define a CPT class like this (you still need Wordpress PHP code!):
36
+
37
+ ```ruby
38
+ class ProgrammingLanguage < Compostr::CustomPostType
39
+ wp_post_type 'programming_language' # `post_type` as known by WP
40
+ wp_custom_field_single 'awesomeness' # 'meta' field in WP, just one value is queried and set
41
+ wp_custom_field_multi 'further_links' # 'meta' field(s) in WP, can have multiple values
42
+ end
43
+ ```
44
+
45
+ Now `ProgrammingLanguage`s can be queried and posted to your Wordpress installation. Instances of this class will automatically respond to `content`, `id`, `title` and `featured_image_id` (corresponding to the Wordpress `post_content`, `id`, `post_title` and `featured_image_id`).
46
+
47
+ Compostr comes prepared with `UUID` information of CPT instances, to e.g. distinctlive identify entities across different WP instances where entities might have different `post_id`s..
48
+
49
+ ### Configuration
50
+
51
+ Global configuration is given in `compostr.conf`, where connection information to the Wordpress installation is defined:
52
+
53
+ # compostr.conf
54
+ host: "wordpress.mydomain"
55
+ username: "admin"
56
+ password: "buzzword"
57
+ language_term: "Deutsch"
58
+ author_id: 1
59
+
60
+ ### Logging/Logger
61
+
62
+ Although logging should not be a main Compostr concern, it was helpful to include some handy helpers.
63
+
64
+ Use Compostr::logger if you want to feed Compostrs logs into your main applications log or redirect them somewhere.
65
+
66
+ To mixin `info`, `warn` and other logging functions into your class/module do an `include Compostr::Logging`.
67
+
68
+ To make Compostr-logs use your logger, set it like this: `Compostr::logger = <mylogger`.
69
+
70
+ ### EntityCache
71
+
72
+ Until you provide some Wordpress PHP code to query custom post types via their Custom (meta) Fields, to query and work with CPTs, all data will be read into memory using `Compostr::EntityCache`.
73
+
74
+ ### Syncer
75
+
76
+ The `Syncer` class deals with wordpress data updates.
77
+
78
+ To avoid re-creation of Posts and the respecive Meta Fields on update actions, prior cache population is needed and employed.
79
+
80
+ ### Image Upload/Featured images
81
+
82
+ Images can be uploaded to Wordpress using the `ImageUploader` class, which comes with a Cache to avoid duplicate upload of images (where "duplication" reduces to "same name"!).
83
+
84
+ ## Development
85
+
86
+ After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
87
+
88
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
89
+
90
+ ## Contributing
91
+
92
+ Bug reports and pull requests are welcome on GitHub at https://github.com/ecovillage/compostr. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
93
+
94
+ That said, just drop me a line.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList['test/**/*_test.rb']
8
+ end
9
+
10
+ task :default => :test
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "compostr"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
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/compostr.gemspec ADDED
@@ -0,0 +1,32 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'compostr/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "compostr"
8
+ spec.version = Compostr::VERSION
9
+ spec.authors = ["Felix Wolfsteller"]
10
+ spec.email = ["felix.wolfsteller@gmail.com"]
11
+
12
+ spec.summary = %q{Ease interaction with Custom Post Types of a Wordpress installation.}
13
+ spec.description = %q{One way to ask a wordpress installation about specific custom post type instances and tell it about them.}
14
+ spec.homepage = 'https://github.com/ecovillage/compostr'
15
+ spec.licenses = ['GPL-3.0+']
16
+
17
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
18
+ spec.bindir = "exe"
19
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
+ spec.require_paths = ["lib"]
21
+
22
+ spec.required_ruby_version = '~> 2.3.1'
23
+
24
+ spec.add_dependency "rubypress", '~> 1.2'
25
+ spec.add_dependency "mime-types", '~> 3.1'
26
+
27
+ spec.add_development_dependency "minitest", '~> 5.0'
28
+ spec.add_development_dependency "bundler", "~> 1.11"
29
+ spec.add_development_dependency "rake", "~> 10.0"
30
+ spec.add_development_dependency "webmock"
31
+ spec.add_development_dependency "vcr"
32
+ end
data/lib/compostr.rb ADDED
@@ -0,0 +1,60 @@
1
+ require "compostr/version"
2
+
3
+ require 'ostruct'
4
+ require 'yaml'
5
+ require 'rubypress'
6
+
7
+ require 'compostr/logging'
8
+
9
+ require 'compostr/wp_string'
10
+
11
+ require 'compostr/custom_field_value'
12
+ require 'compostr/custom_post_type'
13
+
14
+ require 'compostr/entity_cache'
15
+ require 'compostr/media_library_cache'
16
+
17
+ require 'compostr/image_upload'
18
+ require 'compostr/image_uploader'
19
+
20
+ require 'compostr/syncer'
21
+
22
+ # Get the loggers, get the config, expose WP
23
+ module Compostr
24
+ # Load and memoize 'compostr.conf'.
25
+ def self.load_conf
26
+ @@config = OpenStruct.new YAML.load_file 'compostr.conf'
27
+ end
28
+
29
+ # Access configuration hash.
30
+ def self.config
31
+ @@config ||= load_conf
32
+ end
33
+
34
+ # Access (and/or initialize) Rubypress client, settings initially pulled
35
+ # from the configuration.
36
+ def self.wp
37
+ @wp ||= Rubypress::Client.new(host: config.host,
38
+ username: config.username,
39
+ password: config.password)
40
+ end
41
+
42
+ # Access the logger, initialize and memoize it on demand.
43
+ def self.logger
44
+ @@logger ||= Logger.new(STDOUT)
45
+ end
46
+
47
+ # Set the logger.
48
+ def self.logger= logger
49
+ @@logger = logger
50
+ end
51
+
52
+ # Delete a post with given wordpress post_id
53
+ def self.delete_post post_id
54
+ begin
55
+ Compostr::wp.deletePost(blog_id: 0, post_id: post_id)
56
+ rescue XMLRPC::FaultException
57
+ false
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,36 @@
1
+ module Compostr
2
+ # Describe a Custom Field Value with optionally an id (corresponding to the WordPress data).
3
+ class CustomFieldValue
4
+ attr_accessor :id, :key, :value
5
+
6
+ def initialize id, key, value
7
+ @id = id
8
+ @key = key
9
+ @value = value
10
+ end
11
+
12
+ # Convert to hash that is consumable by RubyPress/Wordpress.
13
+ # Important that neither key nor value are present for custom field
14
+ # values that should be *deleted* in wordpress instance.
15
+ def to_hash
16
+ if @id
17
+ hsh = { id: @id }
18
+ hsh[:key] = @key if @key
19
+ hsh[:value] = @value if @value
20
+ hsh
21
+ else
22
+ hsh = {}
23
+ hsh[:key] = @key if @key
24
+ hsh[:value] = @value if @value
25
+ hsh
26
+ end
27
+ end
28
+ end
29
+
30
+ # CustomField NullValue Object.
31
+ class NullCustomFieldValue
32
+ def id; nil; end
33
+ def key; nil; end
34
+ def value; nil; end
35
+ end
36
+ end
@@ -0,0 +1,386 @@
1
+ module Compostr
2
+ # Base class to inherit from for Classes that map to Wordpress
3
+ # Custom Post Types.
4
+ #
5
+ # Besides the post_id, title, content and featured_image (id) that
6
+ # define a post, the CustomPostType likely will own custom field
7
+ # values. These are specified with wp_custom_field_single and wp_custom_field_multi (depending on their type).
8
+ #
9
+ # To loop over the fields, use @fields and @multi_fields.
10
+ class CustomPostType
11
+ attr_accessor :post_id, :title, :content, :featured_image_id
12
+ # TODO rename to single_fields?
13
+ attr_accessor :fields, :multi_fields
14
+
15
+ # Define accessor method to the POST_TYPE (Class#post_type and
16
+ # Instance#post_type).
17
+ def self.wp_post_type(wp_post_type)
18
+ # TODO syntax: define_method("....")
19
+
20
+ # Class wide variable (could also be a constant)
21
+ self.class_eval("POST_TYPE = '#{wp_post_type}'.freeze")
22
+ # Class accessor method
23
+ # def self.post_type
24
+ # POST_TYPE
25
+ # end
26
+ self.class_eval("def self.post_type; POST_TYPE; end")
27
+ # Instance accessor method
28
+ # def post_type
29
+ # POST_TYPE
30
+ # end
31
+ self.class_eval("def post_type; POST_TYPE; end")
32
+ end
33
+
34
+ # Defines accessor methods for the field, which will only
35
+ # allow a single value.
36
+ #
37
+ # Note that the accessor only wears strings and automatically strips
38
+ def self.wp_custom_field_single(field_key)
39
+ # def field_key=(new_value)
40
+ # field!('field_key') = new_value.to_s.strip
41
+ # end
42
+ self.class_eval("def #{field_key.to_s}=(new_value); field!('#{field_key.to_s}').value = WPString.wp_string(new_value); end")
43
+ # def field_key
44
+ # field?(field_key).value
45
+ # end
46
+ self.class_eval("def #{field_key.to_s}; return field?('#{field_key.to_s}').value; end")
47
+
48
+ # Add field to @supported_(single_)fields.
49
+ # This is declared in the class, thus a kindof CLASS variable!
50
+ self.class_eval("(@supported_single_fields ||= []) << '#{field_key}'")
51
+ end
52
+
53
+ # Specify a field that will make and take a fine array.
54
+ def self.wp_custom_field_multi(field_key)
55
+ # def field_key=(new_value)
56
+ # multi_field('field_key') = new_value.map{|v| CustomFieldValue.new(nil, 'field_key', v)}
57
+ # end
58
+ # TODO recycle!
59
+ self.class_eval("def #{field_key.to_s}=(new_value); @multi_fields['#{field_key.to_s}'] = new_value.map{|v| CustomFieldValue.new(nil, '#{field_key.to_s}', v)}; end")
60
+ # def field_key
61
+ # multi_field(field_key).map(&:value).compact
62
+ # end
63
+ self.class_eval("def #{field_key.to_s}; return multi_field('#{field_key.to_s}').map(&:value).compact; end")
64
+
65
+ # Add field to @supported_(multi_)fields.
66
+ # This is declared in the class, thus a kindof CLASS variable!
67
+ self.class_eval("(@supported_multi_fields ||= []) << '#{field_key}'")
68
+ end
69
+
70
+ # Alias the post_title getter and setter with another 'name'.
71
+ def self.wp_post_title_alias(title_alias)
72
+ self.class_eval("alias :#{title_alias.to_sym}= :title=")
73
+ self.class_eval("alias :#{title_alias.to_sym} :title")
74
+ end
75
+
76
+ # Alias the post_content getter and setter with another 'name'.
77
+ def self.wp_post_content_alias(content_alias)
78
+ self.class_eval("alias :#{content_alias.to_sym}= :content=")
79
+ self.class_eval("alias :#{content_alias.to_sym} :content")
80
+ end
81
+
82
+ # This is an instance variable for the Class (not for instances of it)!
83
+ # Three values are allowed: :ignore, :delete, :add
84
+ # And this should actually be two variables: one for
85
+ # from_content_hash and one for integrate_ids
86
+ @additional_field_action = :ignore
87
+
88
+ # Define whether additional custom fields should be
89
+ # :ignore -> ignored (default)
90
+ # :delete -> marked for deletion
91
+ # :add -> added
92
+ # Other values for action will silently be ignored.
93
+ def self.additional_field_action(action)
94
+ if [:ignore, :delete, :add].include? action.to_sym
95
+ # @additional_field_action = :action
96
+ self.class_eval("@additional_field_action = :#{action}")
97
+ end
98
+ end
99
+
100
+ def additional_field_action
101
+ self.class.instance_variable_get(:@additional_field_action) || :ignore
102
+ end
103
+
104
+ def initialize **kwargs
105
+ @fields = Hash.new
106
+ # This one is painful, maybe field? and field!?
107
+ #@fields.default_proc = proc do |hash, key|
108
+ # hash[key] = CustomFieldValue.new(nil, key, nil)
109
+ #end
110
+ @multi_fields = Hash.new
111
+ @multi_fields.default_proc = proc do |hash, key|
112
+ hash[key] = []
113
+ end
114
+ kwargs.each do |k,v|
115
+ if k == :title
116
+ # strip ?
117
+ @title = v
118
+ elsif k == :content
119
+ @content = v
120
+ elsif k == :post_id
121
+ @post_id = v
122
+ elsif k == :featured_image_id
123
+ @featured_image_id = v
124
+ # Better: has_custom_field?
125
+ elsif respond_to?(k.to_sym)
126
+ self.send(((k.to_s) + "=").to_sym, v)
127
+ elsif additional_field_action == :add
128
+ @fields[k.to_sym] = CustomFieldValue.new(nil, k.to_sym, v)
129
+ end
130
+ end
131
+ end
132
+
133
+ def custom_fields_hash
134
+ @fields.values.map(&:to_hash)
135
+ end
136
+
137
+ # Access the given field, returns a NullCustomFieldValue if not found.
138
+ # The NullCustomFieldValue does not accept setting any values and
139
+ # returns nil for id, key and value.
140
+ #
141
+ # Use this to (readonly) access a field with given name.
142
+ def field?(field_name)
143
+ if @fields.key? field_name
144
+ @fields[field_name]
145
+ else
146
+ NullCustomFieldValue.new
147
+ end
148
+ end
149
+
150
+ # Access (or create) a CustomFieldValue that can hold a single value.
151
+ def field!(field_name)
152
+ # ||= would probably do, too.
153
+ if @fields.key? field_name
154
+ @fields[field_name]
155
+ else
156
+ @fields[field_name] = CustomFieldValue.new(nil, field_name, nil)
157
+ end
158
+ end
159
+
160
+ # Access a CustomFieldValue that can hold multiple values (array).
161
+ def multi_field(field_name)
162
+ @multi_fields[field_name]
163
+ end
164
+
165
+ # Returns list of field keys generally supported by this Custom Post Type.
166
+ def supported_fields
167
+ self.class.supported_fields
168
+ end
169
+
170
+ # Returns list of field keys generally supported by this Custom Post Type.
171
+ def self.supported_fields
172
+ supported_single_fields | supported_multi_fields
173
+ end
174
+
175
+ # Returns list of single-valued field keys generally supported
176
+ # by this Custom Post Type.
177
+ def self.supported_single_fields
178
+ instance_variable_get(:@supported_single_fields) || []
179
+ end
180
+
181
+ # Returns list of multiple-valued field keys generally supported
182
+ # by this Custom Post Type.
183
+ def self.supported_multi_fields
184
+ instance_variable_get(:@supported_multi_fields) || []
185
+ end
186
+
187
+ # True iff supported fields include field_name
188
+ def has_custom_field? field_name
189
+ supported_fields.include? field_name
190
+ end
191
+
192
+ # From a Hash as returned by RubyPress's getPost(s) method
193
+ # populate and return a new CustomPostType-instance.
194
+ #
195
+ # Custom field values will be created as specified by
196
+ # the wp_custom_field_single/multi definitions.
197
+ def self.from_content_hash content_hash
198
+ return nil if content_hash.nil?
199
+ entity = new(post_id: content_hash["post_id"],
200
+ content: content_hash["post_content"],
201
+ title: content_hash["post_title"])
202
+
203
+ custom_fields_list = content_hash["custom_fields"] || []
204
+
205
+ supported_fields.each do |field_key|
206
+ #puts "iterating over supported field #{field_key}"
207
+ if is_single_field? field_key
208
+ # Here: duplicate deletion possible
209
+ field = custom_fields_list.find{|f| f["key"] == field_key}
210
+ if field
211
+ entity.send("#{field_key}=".to_sym, field["value"])
212
+ entity.field?(field_key).id = field["id"]
213
+ end
214
+ else
215
+ fields = custom_fields_list.select{|f| f["key"] == field_key}
216
+ values = fields.map{|f| f["value"]}
217
+ entity.send("#{field_key}=".to_sym, values)
218
+ # Not elegant: Set the id one per one
219
+ fields.each do |f|
220
+ entity.set_field_id(field_key, f["value"], f["id"])
221
+ end
222
+ end
223
+ end
224
+ # if additional fields add, add these
225
+
226
+ entity
227
+ end
228
+
229
+ def to_content_hash
230
+ content = {
231
+ post_type: post_type,
232
+ post_status: 'publish',
233
+ post_data: Time.now,
234
+ post_title: title || '', # why does content need '@'?
235
+ post_content: @content || '',
236
+ custom_fields: @fields.map{|k,v| v.to_hash} | @multi_fields.flat_map{|k,v| v.flat_map(&:to_hash)}
237
+ }
238
+ if featured_image_id
239
+ content[:post_thumbnail] = featured_image_id.to_s
240
+ end
241
+ content
242
+ end
243
+
244
+ # When additional_field_action == :ignore (the default) sets (wp) ids
245
+ # of fields for which values are set.
246
+ #
247
+ # If additional_field_action == :add CustomFieldValues of other_entity are
248
+ # copied if not yet existing in this entity (otherwise only the id of
249
+ # the fields are set.
250
+ #
251
+ # Finally, if additional_field_action == :delete , mark the fields which
252
+ # are NOT set in this entity but in the other entity ready for deletion.
253
+ #
254
+ # The ids are taken from other_entity (if available, left empty otherwise).
255
+ def integrate_field_ids other_entity
256
+ # TODO rename and/or restructure this method
257
+ # new from old
258
+ fields.values.each do |f|
259
+ if f.key.start_with? 'ref'
260
+ puts "foreign fields : #{other_entity.fields.keys}"
261
+ puts "foreign fields : #{other_entity.fields.values.map{|v| v.id.to_s + ' ' + v.id.class.to_s}}"
262
+ puts "integrate field : #{f.key} #{f.inspect}"
263
+ puts " other field : #{other_entity.field?(f.key).inspect}"
264
+ end
265
+ f.id = other_entity.field?(f.key).id
266
+ end
267
+
268
+ if additional_field_action == :add
269
+ # old to new
270
+ other_entity.fields.values.each do |f|
271
+ if !@fields.key?(f.key)
272
+ @fields[f.key] = f
273
+ end
274
+ end
275
+ elsif additional_field_action == :delete
276
+ other_entity.fields.values.each do |f|
277
+ if !@fields.key?(f.key)
278
+ # This field will be deleted when used to edit Post
279
+ @fields[f.key] = CustomFieldValue.new(f.id, nil, nil)
280
+ end
281
+ end
282
+ end
283
+
284
+ @multi_fields.each do |field_name, mf|
285
+ ids = other_entity.multi_field(field_name).map(&:id)
286
+ mf.each do |mf_entry|
287
+ mf_entry.id = ids.delete_at(0) # keep order, use #pop otherwise
288
+ end
289
+ # If any ids left, delete these custom fields
290
+ ids.each do |id|
291
+ multi_field(field_name) << CustomFieldValue.new(id, nil, nil)
292
+ end
293
+ end
294
+ end
295
+
296
+ def is_multi_field?(field_name)
297
+ self.class.is_multi_field?(field_name)
298
+ end
299
+
300
+ def self.is_multi_field?(field_name)
301
+ supported_multi_fields.include? field_name
302
+ end
303
+
304
+ def is_single_field?(field_name)
305
+ self.class.is_single_field?(field_name)
306
+ end
307
+
308
+ def self.is_single_field?(field_name)
309
+ supported_single_fields.include? field_name
310
+ end
311
+
312
+ def in_wordpress?
313
+ post_id.to_s != '' && !!post_id
314
+ end
315
+
316
+ def set_field_id field_key, field_value, field_id
317
+ if is_single_field? field_key
318
+ # ????!! field!
319
+ field?(field_key).id = field_id
320
+ else
321
+ multi_field(field_key).find{|f| f.key == field_key && f.value == field_value}.id = field_id
322
+ end
323
+ end
324
+
325
+ # Returns hash where keys are field names where the values differ.
326
+ # values of returned hash are arrays like
327
+ # [own_value, other_different_value].
328
+ # Returns empty hash to signalize equaliness.
329
+ def diff(other_cpt_object)
330
+ if other_cpt_object.nil?
331
+ other_cpt_object = NullCustomPostType.new
332
+ end
333
+
334
+ diff_fields = {}
335
+ # Fields exclusive to this one.
336
+ (@fields.keys - other_cpt_object.fields.keys).each do |f|
337
+ diff_fields[f] = [@fields[f].value, nil]
338
+ end
339
+ # Fields exclusive to the other.
340
+ (other_cpt_object.fields.keys - @fields.keys).each do |f|
341
+ diff_fields[f] = [nil, other_cpt_object.fields[f].value]
342
+ end
343
+ # Mutual fields
344
+ (@fields.keys | other_cpt_object.fields.keys).each do |f|
345
+ field_value = field?(f).value
346
+ other_field_value = other_cpt_object.field?(f).value
347
+ if other_field_value != field_value
348
+ diff_fields[f] = [field_value, other_field_value]
349
+ end
350
+ end
351
+ # Multi-Fields exclusive to this one.
352
+ (@multi_fields.keys - other_cpt_object.multi_fields.keys).each do |f|
353
+ diff_fields[f] = [@multi_fields[f].map(&:value), nil]
354
+ end
355
+ # Multi-Fields exclusive to the other.
356
+ (other_cpt_object.multi_fields.keys - @multi_fields.keys).each do |f|
357
+ diff_fields[f] = [nil, other_cpt_object.multi_fields[f].value]
358
+ end
359
+ # Mutual Multi-fields
360
+ (@multi_fields.keys | other_cpt_object.multi_fields.keys).each do |f|
361
+ field_values = multi_field(f).map(&:value).compact
362
+ other_field_values = other_cpt_object.multi_field(f).map(&:value).compact
363
+ if other_field_values != field_values
364
+ diff_fields[f] = [field_values, other_field_values]
365
+ end
366
+ end
367
+ if @title.to_s.strip != other_cpt_object.title.to_s.strip
368
+ diff_fields["title"] = [@title, other_cpt_object.title]
369
+ end
370
+ if @featured_image_id != other_cpt_object.featured_image_id
371
+ diff_fields["featured_image_id"] = [@featured_image_id, other_cpt_object.featured_image_id]
372
+ end
373
+ if @content.to_s.strip != other_cpt_object.content.to_s.strip
374
+ diff_fields["content"] = [@content, other_cpt_object.content]
375
+ end
376
+ diff_fields
377
+ end
378
+
379
+ def different_from? other_cpt_object
380
+ !diff(other_cpt_object).empty?
381
+ end
382
+ end
383
+
384
+ class NullCustomPostType < CustomPostType
385
+ end
386
+ end