alchemy_cms 5.2.4 → 6.0.0.b1

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.
Files changed (269) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +6 -14
  3. data/.gitignore +0 -1
  4. data/.hound.yml +1 -1
  5. data/.rubocop.yml +46 -4
  6. data/CHANGELOG.md +80 -25
  7. data/Gemfile +4 -2
  8. data/README.md +5 -2
  9. data/alchemy_cms.gemspec +78 -65
  10. data/app/assets/javascripts/alchemy/admin.js +0 -2
  11. data/app/assets/javascripts/alchemy/alchemy.base.js.coffee +0 -27
  12. data/app/assets/javascripts/alchemy/alchemy.confirm_dialog.js.coffee +2 -1
  13. data/app/assets/javascripts/alchemy/alchemy.dialog.js.coffee +1 -1
  14. data/app/assets/javascripts/alchemy/alchemy.dragndrop.js.coffee +0 -25
  15. data/app/assets/javascripts/alchemy/alchemy.element_editors.js.coffee +1 -1
  16. data/app/assets/javascripts/alchemy/alchemy.elements_window.js.coffee +2 -0
  17. data/app/assets/javascripts/alchemy/alchemy.fixed_elements.js +1 -1
  18. data/app/assets/javascripts/alchemy/alchemy.gui.js.coffee +3 -1
  19. data/app/assets/javascripts/alchemy/alchemy.image_overlay.coffee +1 -1
  20. data/app/assets/javascripts/alchemy/alchemy.link_dialog.js.coffee +40 -27
  21. data/app/assets/javascripts/alchemy/templates/node_folder.hbs +1 -1
  22. data/app/assets/stylesheets/alchemy/admin.scss +1 -1
  23. data/app/assets/stylesheets/alchemy/archive.scss +4 -4
  24. data/app/assets/stylesheets/alchemy/buttons.scss +0 -4
  25. data/app/assets/stylesheets/alchemy/elements.scss +73 -61
  26. data/app/assets/stylesheets/alchemy/images.scss +8 -0
  27. data/app/assets/stylesheets/alchemy/node-select.scss +4 -3
  28. data/app/assets/stylesheets/alchemy/page-select.scss +1 -0
  29. data/app/assets/stylesheets/tinymce/skins/alchemy/skin.min.css.scss +6 -6
  30. data/app/controllers/alchemy/admin/attachments_controller.rb +6 -2
  31. data/app/controllers/alchemy/admin/base_controller.rb +5 -7
  32. data/app/controllers/alchemy/admin/elements_controller.rb +58 -34
  33. data/app/controllers/alchemy/admin/essence_audios_controller.rb +30 -0
  34. data/app/controllers/alchemy/admin/essence_files_controller.rb +0 -14
  35. data/app/controllers/alchemy/admin/essence_pictures_controller.rb +8 -79
  36. data/app/controllers/alchemy/admin/essence_videos_controller.rb +33 -0
  37. data/app/controllers/alchemy/admin/ingredients_controller.rb +30 -0
  38. data/app/controllers/alchemy/admin/layoutpages_controller.rb +0 -1
  39. data/app/controllers/alchemy/admin/pages_controller.rb +6 -13
  40. data/app/controllers/alchemy/admin/pictures_controller.rb +35 -9
  41. data/app/controllers/alchemy/api/elements_controller.rb +10 -5
  42. data/app/controllers/alchemy/api/pages_controller.rb +2 -4
  43. data/app/controllers/concerns/alchemy/admin/archive_overlay.rb +13 -3
  44. data/app/controllers/concerns/alchemy/admin/crop_action.rb +26 -0
  45. data/app/decorators/alchemy/element_editor.rb +23 -1
  46. data/app/decorators/alchemy/ingredient_editor.rb +154 -0
  47. data/app/helpers/alchemy/admin/elements_helper.rb +1 -0
  48. data/app/helpers/alchemy/admin/essences_helper.rb +1 -1
  49. data/app/helpers/alchemy/admin/ingredients_helper.rb +42 -0
  50. data/app/helpers/alchemy/elements_block_helper.rb +22 -7
  51. data/app/helpers/alchemy/elements_helper.rb +12 -5
  52. data/app/helpers/alchemy/pages_helper.rb +3 -11
  53. data/app/jobs/alchemy/base_job.rb +11 -0
  54. data/app/jobs/alchemy/publish_page_job.rb +11 -0
  55. data/app/models/alchemy/attachment.rb +1 -1
  56. data/app/models/alchemy/content/factory.rb +23 -27
  57. data/app/models/alchemy/content.rb +1 -6
  58. data/app/models/alchemy/element/definitions.rb +29 -27
  59. data/app/models/alchemy/element/element_contents.rb +131 -122
  60. data/app/models/alchemy/element/element_essences.rb +100 -98
  61. data/app/models/alchemy/element/element_ingredients.rb +176 -0
  62. data/app/models/alchemy/element/presenters.rb +89 -87
  63. data/app/models/alchemy/element.rb +40 -73
  64. data/app/models/alchemy/elements_repository.rb +126 -0
  65. data/app/models/alchemy/essence_audio.rb +12 -0
  66. data/app/models/alchemy/essence_headline.rb +40 -0
  67. data/app/models/alchemy/essence_picture.rb +4 -116
  68. data/app/models/alchemy/essence_richtext.rb +12 -0
  69. data/app/models/alchemy/essence_video.rb +12 -0
  70. data/app/models/alchemy/image_cropper_settings.rb +87 -0
  71. data/app/models/alchemy/ingredient.rb +219 -0
  72. data/app/models/alchemy/ingredient_validator.rb +97 -0
  73. data/app/models/alchemy/ingredients/audio.rb +29 -0
  74. data/app/models/alchemy/ingredients/boolean.rb +21 -0
  75. data/app/models/alchemy/ingredients/datetime.rb +20 -0
  76. data/app/models/alchemy/ingredients/file.rb +30 -0
  77. data/app/models/alchemy/ingredients/headline.rb +42 -0
  78. data/app/models/alchemy/ingredients/html.rb +19 -0
  79. data/app/models/alchemy/ingredients/link.rb +16 -0
  80. data/app/models/alchemy/ingredients/node.rb +23 -0
  81. data/app/models/alchemy/ingredients/page.rb +23 -0
  82. data/app/models/alchemy/ingredients/picture.rb +41 -0
  83. data/app/models/alchemy/ingredients/richtext.rb +57 -0
  84. data/app/models/alchemy/ingredients/select.rb +10 -0
  85. data/app/models/alchemy/ingredients/text.rb +17 -0
  86. data/app/models/alchemy/ingredients/video.rb +33 -0
  87. data/app/models/alchemy/language.rb +0 -11
  88. data/app/models/alchemy/node.rb +1 -1
  89. data/app/models/alchemy/page/fixed_attributes.rb +53 -51
  90. data/app/models/alchemy/page/page_elements.rb +186 -205
  91. data/app/models/alchemy/page/page_naming.rb +66 -64
  92. data/app/models/alchemy/page/page_natures.rb +139 -142
  93. data/app/models/alchemy/page/page_scopes.rb +113 -102
  94. data/app/models/alchemy/page/publisher.rb +50 -0
  95. data/app/models/alchemy/page/url_path.rb +1 -1
  96. data/app/models/alchemy/page.rb +67 -33
  97. data/app/models/alchemy/page_version.rb +58 -0
  98. data/app/models/alchemy/picture/calculations.rb +2 -8
  99. data/app/models/alchemy/picture/preprocessor.rb +2 -0
  100. data/app/models/alchemy/picture/transformations.rb +24 -96
  101. data/app/models/alchemy/picture.rb +4 -2
  102. data/app/models/concerns/alchemy/picture_thumbnails.rb +181 -0
  103. data/app/models/concerns/alchemy/touch_elements.rb +2 -2
  104. data/app/presenters/alchemy/picture_view.rb +88 -0
  105. data/app/serializers/alchemy/element_serializer.rb +5 -0
  106. data/app/serializers/alchemy/page_tree_serializer.rb +3 -2
  107. data/app/services/alchemy/delete_elements.rb +44 -0
  108. data/app/services/alchemy/duplicate_element.rb +56 -0
  109. data/app/views/alchemy/admin/attachments/_archive_overlay.html.erb +1 -2
  110. data/app/views/alchemy/admin/attachments/_file_to_assign.html.erb +3 -3
  111. data/app/views/alchemy/admin/attachments/assign.js.erb +11 -0
  112. data/app/views/alchemy/admin/crop.html.erb +36 -0
  113. data/app/views/alchemy/admin/elements/_element.html.erb +14 -10
  114. data/app/views/alchemy/admin/elements/{_element_footer.html.erb → _footer.html.erb} +0 -0
  115. data/app/views/alchemy/admin/elements/{_new_element_form.html.erb → _form.html.erb} +1 -1
  116. data/app/views/alchemy/admin/elements/{_element_header.html.erb → _header.html.erb} +1 -1
  117. data/app/views/alchemy/admin/elements/{_element_toolbar.html.erb → _toolbar.html.erb} +5 -6
  118. data/app/views/alchemy/admin/elements/{trash.js.erb → destroy.js.erb} +1 -3
  119. data/app/views/alchemy/admin/elements/new.html.erb +3 -3
  120. data/app/views/alchemy/admin/elements/order.js.erb +0 -17
  121. data/app/views/alchemy/admin/elements/update.js.erb +3 -2
  122. data/app/views/alchemy/admin/essence_audios/edit.html.erb +7 -0
  123. data/app/views/alchemy/admin/essence_pictures/update.js.erb +0 -1
  124. data/app/views/alchemy/admin/essence_videos/edit.html.erb +11 -0
  125. data/app/views/alchemy/admin/ingredients/_audio_fields.html.erb +4 -0
  126. data/app/views/alchemy/admin/ingredients/_file_fields.html.erb +18 -0
  127. data/app/views/alchemy/admin/ingredients/_picture_fields.html.erb +25 -0
  128. data/app/views/alchemy/admin/ingredients/_video_fields.html.erb +8 -0
  129. data/app/views/alchemy/admin/ingredients/edit.html.erb +4 -0
  130. data/app/views/alchemy/admin/layoutpages/edit.html.erb +0 -5
  131. data/app/views/alchemy/admin/nodes/_node.html.erb +2 -2
  132. data/app/views/alchemy/admin/pages/_anchor_link.html.erb +1 -1
  133. data/app/views/alchemy/admin/pages/_external_link.html.erb +1 -1
  134. data/app/views/alchemy/admin/pages/_file_link.html.erb +1 -1
  135. data/app/views/alchemy/admin/pages/_form.html.erb +0 -6
  136. data/app/views/alchemy/admin/pages/_internal_link.html.erb +1 -1
  137. data/app/views/alchemy/admin/pages/_tinymce_custom_config.html.erb +5 -2
  138. data/app/views/alchemy/admin/pages/edit.html.erb +36 -24
  139. data/app/views/alchemy/admin/partials/_remote_search_form.html.erb +2 -4
  140. data/app/views/alchemy/admin/partials/_routes.html.erb +7 -11
  141. data/app/views/alchemy/admin/pictures/_filter_and_size_bar.html.erb +4 -8
  142. data/app/views/alchemy/admin/pictures/_infos.html.erb +0 -1
  143. data/app/views/alchemy/admin/pictures/_picture_to_assign.html.erb +4 -4
  144. data/app/views/alchemy/admin/pictures/assign.js.erb +10 -0
  145. data/app/views/alchemy/admin/resources/_form.html.erb +1 -0
  146. data/app/views/alchemy/essences/_essence_audio_editor.html.erb +4 -0
  147. data/app/views/alchemy/essences/_essence_audio_view.html.erb +15 -0
  148. data/app/views/alchemy/essences/_essence_file_editor.html.erb +15 -6
  149. data/app/views/alchemy/essences/_essence_headline_editor.html.erb +36 -0
  150. data/app/views/alchemy/essences/_essence_headline_view.html.erb +10 -0
  151. data/app/views/alchemy/essences/_essence_link_editor.html.erb +8 -4
  152. data/app/views/alchemy/essences/_essence_picture_editor.html.erb +27 -12
  153. data/app/views/alchemy/essences/_essence_text_editor.html.erb +12 -4
  154. data/app/views/alchemy/essences/_essence_video_editor.html.erb +4 -0
  155. data/app/views/alchemy/essences/_essence_video_view.html.erb +18 -0
  156. data/app/views/alchemy/essences/shared/_essence_picture_tools.html.erb +21 -16
  157. data/app/views/alchemy/essences/shared/_linkable_essence_tools.html.erb +2 -2
  158. data/app/views/alchemy/ingredients/_audio_editor.html.erb +5 -0
  159. data/app/views/alchemy/ingredients/_audio_view.html.erb +14 -0
  160. data/app/views/alchemy/ingredients/_boolean_editor.html.erb +11 -0
  161. data/app/views/alchemy/ingredients/_boolean_view.html.erb +1 -0
  162. data/app/views/alchemy/ingredients/_datetime_editor.html.erb +17 -0
  163. data/app/views/alchemy/ingredients/_datetime_view.html.erb +9 -0
  164. data/app/views/alchemy/ingredients/_file_editor.html.erb +50 -0
  165. data/app/views/alchemy/ingredients/_file_view.html.erb +17 -0
  166. data/app/views/alchemy/ingredients/_headline_editor.html.erb +30 -0
  167. data/app/views/alchemy/ingredients/_headline_view.html.erb +9 -0
  168. data/app/views/alchemy/ingredients/_html_editor.html.erb +8 -0
  169. data/app/views/alchemy/ingredients/_html_view.html.erb +1 -0
  170. data/app/views/alchemy/ingredients/_link_editor.html.erb +24 -0
  171. data/app/views/alchemy/ingredients/_link_view.html.erb +9 -0
  172. data/app/views/alchemy/ingredients/_node_editor.html.erb +25 -0
  173. data/app/views/alchemy/ingredients/_node_view.html.erb +1 -0
  174. data/app/views/alchemy/ingredients/_page_editor.html.erb +24 -0
  175. data/app/views/alchemy/ingredients/_page_view.html.erb +4 -0
  176. data/app/views/alchemy/ingredients/_picture_editor.html.erb +59 -0
  177. data/app/views/alchemy/ingredients/_picture_view.html.erb +5 -0
  178. data/app/views/alchemy/ingredients/_richtext_editor.html.erb +12 -0
  179. data/app/views/alchemy/ingredients/_richtext_view.html.erb +3 -0
  180. data/app/views/alchemy/ingredients/_select_editor.html.erb +29 -0
  181. data/app/views/alchemy/ingredients/_select_view.html.erb +1 -0
  182. data/app/views/alchemy/ingredients/_text_editor.html.erb +19 -0
  183. data/app/views/alchemy/ingredients/_text_view.html.erb +16 -0
  184. data/app/views/alchemy/ingredients/_video_editor.html.erb +5 -0
  185. data/app/views/alchemy/ingredients/_video_view.html.erb +17 -0
  186. data/app/views/alchemy/ingredients/shared/_link_tools.html.erb +20 -0
  187. data/app/views/alchemy/ingredients/shared/_picture_tools.html.erb +57 -0
  188. data/config/brakeman.ignore +66 -159
  189. data/config/initializers/dragonfly.rb +10 -0
  190. data/config/locales/alchemy.en.yml +23 -15
  191. data/config/routes.rb +17 -22
  192. data/db/migrate/20201207131309_create_page_versions.rb +19 -0
  193. data/db/migrate/20201207135820_add_page_version_id_to_alchemy_elements.rb +76 -0
  194. data/db/migrate/20210205143548_rename_public_on_and_public_until_on_alchemy_pages.rb +10 -0
  195. data/db/migrate/20210326105046_add_sanitized_body_to_alchemy_essence_richtexts.rb +7 -0
  196. data/db/migrate/20210406093436_add_alchemy_essence_headlines.rb +12 -0
  197. data/db/migrate/20210506135919_create_essence_audios.rb +19 -0
  198. data/db/migrate/20210506140258_create_essence_videos.rb +23 -0
  199. data/db/migrate/20210508091432_create_alchemy_ingredients.rb +22 -0
  200. data/lib/alchemy/admin/preview_url.rb +2 -0
  201. data/lib/alchemy/deprecation.rb +1 -1
  202. data/lib/alchemy/dragonfly/processors/auto_orient.rb +18 -0
  203. data/lib/alchemy/dragonfly/processors/crop_resize.rb +35 -0
  204. data/lib/alchemy/elements_finder.rb +14 -60
  205. data/lib/alchemy/engine.rb +1 -1
  206. data/lib/alchemy/essence.rb +1 -2
  207. data/lib/alchemy/hints.rb +8 -4
  208. data/lib/alchemy/page_layout.rb +0 -13
  209. data/lib/alchemy/permissions.rb +30 -29
  210. data/lib/alchemy/resource.rb +13 -3
  211. data/lib/alchemy/tasks/tidy.rb +29 -0
  212. data/lib/alchemy/test_support/essence_shared_examples.rb +0 -1
  213. data/lib/alchemy/test_support/factories/element_factory.rb +8 -8
  214. data/lib/alchemy/test_support/factories/essence_audio_factory.rb +7 -0
  215. data/lib/alchemy/test_support/factories/essence_video_factory.rb +7 -0
  216. data/lib/alchemy/test_support/factories/ingredient_factory.rb +25 -0
  217. data/lib/alchemy/test_support/factories/page_factory.rb +20 -1
  218. data/lib/alchemy/test_support/factories/page_version_factory.rb +23 -0
  219. data/lib/alchemy/test_support/having_crop_action_examples.rb +170 -0
  220. data/lib/alchemy/test_support/having_picture_thumbnails_examples.rb +646 -0
  221. data/lib/alchemy/test_support/shared_ingredient_editor_examples.rb +21 -0
  222. data/lib/alchemy/test_support/shared_ingredient_examples.rb +57 -0
  223. data/lib/alchemy/test_support.rb +2 -11
  224. data/lib/alchemy/tinymce.rb +17 -0
  225. data/lib/alchemy/upgrader/five_point_zero.rb +0 -32
  226. data/lib/alchemy/upgrader/six_point_zero.rb +21 -0
  227. data/lib/alchemy/upgrader/tasks/add_page_versions.rb +33 -0
  228. data/lib/alchemy/upgrader/tasks/ingredients_migrator.rb +51 -0
  229. data/lib/alchemy/version.rb +1 -1
  230. data/lib/generators/alchemy/elements/elements_generator.rb +1 -0
  231. data/lib/generators/alchemy/elements/templates/view.html.erb +9 -0
  232. data/lib/generators/alchemy/elements/templates/view.html.haml +9 -0
  233. data/lib/generators/alchemy/elements/templates/view.html.slim +9 -0
  234. data/lib/generators/alchemy/ingredient/ingredient_generator.rb +38 -0
  235. data/lib/generators/alchemy/ingredient/templates/editor.html.erb +14 -0
  236. data/lib/generators/alchemy/ingredient/templates/model.rb.tt +13 -0
  237. data/lib/generators/alchemy/ingredient/templates/view.html.erb +1 -0
  238. data/lib/generators/alchemy/install/install_generator.rb +1 -2
  239. data/lib/generators/alchemy/install/templates/dragonfly.rb.tt +1 -1
  240. data/lib/generators/alchemy/menus/templates/node.html.erb +1 -1
  241. data/lib/generators/alchemy/menus/templates/node.html.haml +1 -1
  242. data/lib/generators/alchemy/menus/templates/node.html.slim +1 -1
  243. data/lib/generators/alchemy/menus/templates/wrapper.html.erb +1 -1
  244. data/lib/generators/alchemy/menus/templates/wrapper.html.haml +1 -1
  245. data/lib/generators/alchemy/menus/templates/wrapper.html.slim +1 -1
  246. data/lib/tasks/alchemy/tidy.rake +12 -0
  247. data/lib/tasks/alchemy/upgrade.rake +21 -15
  248. data/package/admin.js +9 -1
  249. data/package/src/file_editors.js +28 -0
  250. data/package/src/image_cropper.js +103 -0
  251. data/package/src/image_loader.js +58 -0
  252. data/package/src/node_tree.js +5 -5
  253. data/package/src/picture_editors.js +169 -0
  254. data/package/src/utils/__tests__/ajax.spec.js +20 -12
  255. data/package/src/utils/ajax.js +8 -3
  256. data/package.json +3 -2
  257. data/vendor/assets/javascripts/jquery_plugins/jquery.Jcrop.min.js +3 -18
  258. data/vendor/assets/stylesheets/jquery.Jcrop.min.scss +2 -28
  259. metadata +285 -56
  260. data/app/assets/javascripts/alchemy/alchemy.image_cropper.js.coffee +0 -44
  261. data/app/assets/javascripts/alchemy/alchemy.trash_window.js.coffee +0 -30
  262. data/app/assets/stylesheets/alchemy/trash.scss +0 -8
  263. data/app/controllers/alchemy/admin/trash_controller.rb +0 -44
  264. data/app/views/alchemy/admin/essence_files/assign.js.erb +0 -3
  265. data/app/views/alchemy/admin/essence_pictures/assign.js.erb +0 -4
  266. data/app/views/alchemy/admin/essence_pictures/crop.html.erb +0 -48
  267. data/app/views/alchemy/admin/trash/clear.js.erb +0 -4
  268. data/app/views/alchemy/admin/trash/index.html.erb +0 -31
  269. data/lib/alchemy/test_support/factories.rb +0 -20
@@ -35,6 +35,12 @@
35
35
  # locked_at :datetime
36
36
  #
37
37
 
38
+ require_dependency "alchemy/page/fixed_attributes"
39
+ require_dependency "alchemy/page/page_scopes"
40
+ require_dependency "alchemy/page/page_natures"
41
+ require_dependency "alchemy/page/page_naming"
42
+ require_dependency "alchemy/page/page_elements"
43
+
38
44
  module Alchemy
39
45
  class Page < BaseRecord
40
46
  include Alchemy::Hints
@@ -82,7 +88,7 @@ module Alchemy
82
88
 
83
89
  acts_as_nested_set(dependent: :destroy, scope: [:layoutpage, :language_id])
84
90
 
85
- stampable stamper_class_name: Alchemy.user_class.name
91
+ stampable stamper_class_name: Alchemy.user_class_name
86
92
 
87
93
  belongs_to :language
88
94
 
@@ -109,6 +115,9 @@ module Alchemy
109
115
  has_many :folded_pages
110
116
  has_many :legacy_urls, class_name: "Alchemy::LegacyPageUrl"
111
117
  has_many :nodes, class_name: "Alchemy::Node", inverse_of: :page
118
+ has_many :versions, class_name: "Alchemy::PageVersion", inverse_of: :page, dependent: :destroy
119
+ has_one :draft_version, -> { drafts }, class_name: "Alchemy::PageVersion"
120
+ has_one :public_version, -> { published }, class_name: "Alchemy::PageVersion"
112
121
 
113
122
  before_validation :set_language,
114
123
  if: -> { language.nil? }
@@ -117,6 +126,9 @@ module Alchemy
117
126
  validates_format_of :page_layout, with: /\A[a-z0-9_-]+\z/, unless: -> { page_layout.blank? }
118
127
  validates_presence_of :parent, unless: -> { layoutpage? || language_root? }
119
128
 
129
+ before_create -> { versions.build },
130
+ if: -> { versions.none? }
131
+
120
132
  before_save :set_language_code,
121
133
  if: -> { language.present? }
122
134
 
@@ -126,9 +138,6 @@ module Alchemy
126
138
  before_save :inherit_restricted_status,
127
139
  if: -> { parent && parent.restricted? }
128
140
 
129
- before_save :set_published_at,
130
- if: -> { public_on.present? && published_at.nil? }
131
-
132
141
  before_save :set_fixed_attributes,
133
142
  if: -> { fixed_attributes.any? }
134
143
 
@@ -138,14 +147,22 @@ module Alchemy
138
147
  after_update -> { nodes.update_all(updated_at: Time.current) }
139
148
 
140
149
  # Concerns
141
- include Alchemy::Page::PageScopes
142
- include Alchemy::Page::PageNatures
143
- include Alchemy::Page::PageNaming
144
- include Alchemy::Page::PageElements
150
+ include PageScopes
151
+ include PageNatures
152
+ include PageNaming
153
+ include PageElements
145
154
 
146
155
  # site_name accessor
147
156
  delegate :name, to: :site, prefix: true, allow_nil: true
148
157
 
158
+ # Old public_on and public_until attributes for historical reasons
159
+ #
160
+ # These attributes now exist on the page versions
161
+ #
162
+ attr_readonly :legacy_public_on, :legacy_public_until
163
+ deprecate :legacy_public_on, deprecator: Alchemy::Deprecation
164
+ deprecate :legacy_public_until, deprecator: Alchemy::Deprecation
165
+
149
166
  # Class methods
150
167
  #
151
168
  class << self
@@ -207,11 +224,13 @@ module Alchemy
207
224
  # @return [Alchemy::Page]
208
225
  #
209
226
  def copy(source, differences = {})
210
- page = Alchemy::Page.new(attributes_from_source_for_copy(source, differences))
211
- page.tag_list = source.tag_list
212
- if page.save!
213
- copy_elements(source, page)
214
- page
227
+ transaction do
228
+ page = Alchemy::Page.new(attributes_from_source_for_copy(source, differences))
229
+ page.tag_list = source.tag_list
230
+ if page.save!
231
+ copy_elements(source, page)
232
+ page
233
+ end
215
234
  end
216
235
  end
217
236
 
@@ -291,7 +310,9 @@ module Alchemy
291
310
  # Instance methods
292
311
  #
293
312
 
294
- # Returns elements from page.
313
+ # Returns elements from pages public version.
314
+ #
315
+ # You can pass another page_version to load elements from in the options.
295
316
  #
296
317
  # @option options [Array<String>|String] :only
297
318
  # Returns only elements with given names
@@ -310,11 +331,14 @@ module Alchemy
310
331
  # @option options [Class] :finder (Alchemy::ElementsFinder)
311
332
  # A class that will return elements from page.
312
333
  # Use this for your custom element loading logic.
334
+ # @option options [Alchemy::PageVersion] :page_version
335
+ # A page version to load elements from.
336
+ # Uses the pages public_version by default.
313
337
  #
314
338
  # @return [ActiveRecord::Relation]
315
339
  def find_elements(options = {})
316
340
  finder = options[:finder] || Alchemy::ElementsFinder.new(options)
317
- finder.elements(page: self)
341
+ finder.elements(page_version: options[:page_version] || public_version)
318
342
  end
319
343
 
320
344
  # = The url_path for this page
@@ -423,22 +447,36 @@ module Alchemy
423
447
  end
424
448
  end
425
449
 
426
- # Publishes the page.
450
+ # Creates a public version of the page.
427
451
  #
428
- # Sets +public_on+ and the +published_at+ value to current time
429
- # and resets +public_until+ to nil
452
+ # Sets the +published_at+ value to current time
430
453
  #
431
454
  # The +published_at+ attribute is used as +cache_key+.
432
455
  #
433
- def publish!
434
- current_time = Time.current
435
- update_columns(
436
- published_at: current_time,
437
- public_on: already_public_for?(current_time) ? public_on : current_time,
438
- public_until: still_public_for?(current_time) ? public_until : nil,
439
- )
456
+ def publish!(current_time = Time.current)
457
+ update(published_at: current_time)
458
+ PublishPageJob.perform_later(self, public_on: current_time)
440
459
  end
441
460
 
461
+ # Sets the public_on date on the published version
462
+ #
463
+ # Builds a new version if none exists yet.
464
+ # Destroys public version if empty time is set
465
+ #
466
+ def public_on=(time)
467
+ if public_version && time.blank?
468
+ public_version.destroy!
469
+ # Need to reset the public version on the instance so we do not need to reload
470
+ self.public_version = nil
471
+ elsif public_version
472
+ public_version.public_on = time
473
+ elsif time.present?
474
+ versions.build(public_on: time)
475
+ end
476
+ end
477
+
478
+ delegate :public_until=, to: :public_version, allow_nil: true
479
+
442
480
  # Updates an Alchemy::Page based on a new ordering to be applied to it
443
481
  #
444
482
  # Note: Page's urls should not be updated (and a legacy URL created) if nesting is OFF
@@ -460,7 +498,7 @@ module Alchemy
460
498
 
461
499
  # Holds an instance of +FixedAttributes+
462
500
  def fixed_attributes
463
- @_fixed_attributes ||= Alchemy::Page::FixedAttributes.new(self)
501
+ @_fixed_attributes ||= FixedAttributes.new(self)
464
502
  end
465
503
 
466
504
  # True if given attribute name is defined as fixed
@@ -479,12 +517,12 @@ module Alchemy
479
517
  (editor_roles & user.alchemy_roles).any?
480
518
  end
481
519
 
482
- # Returns the value of +public_on+ attribute
520
+ # Returns the value of +public_on+ attribute from public version
483
521
  #
484
522
  # If it's a fixed attribute then the fixed value is returned instead
485
523
  #
486
524
  def public_on
487
- attribute_fixed?(:public_on) ? fixed_attributes[:public_on] : self[:public_on]
525
+ attribute_fixed?(:public_on) ? fixed_attributes[:public_on] : public_version&.public_on
488
526
  end
489
527
 
490
528
  # Returns the value of +public_until+ attribute
@@ -492,7 +530,7 @@ module Alchemy
492
530
  # If it's a fixed attribute then the fixed value is returned instead
493
531
  #
494
532
  def public_until
495
- attribute_fixed?(:public_until) ? fixed_attributes[:public_until] : self[:public_until]
533
+ attribute_fixed?(:public_until) ? fixed_attributes[:public_until] : public_version&.public_until
496
534
  end
497
535
 
498
536
  # Returns the name of the creator of this page.
@@ -556,9 +594,5 @@ module Alchemy
556
594
  def create_legacy_url
557
595
  legacy_urls.find_or_create_by(urlname: urlname_before_last_save)
558
596
  end
559
-
560
- def set_published_at
561
- self.published_at = Time.current
562
- end
563
597
  end
564
598
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ class PageVersion < BaseRecord
5
+ belongs_to :page, class_name: "Alchemy::Page", inverse_of: :versions
6
+
7
+ has_many :elements, -> { order(:position) },
8
+ class_name: "Alchemy::Element",
9
+ inverse_of: :page_version
10
+
11
+ scope :drafts, -> { where(public_on: nil).order(updated_at: :desc) }
12
+ scope :published, -> { where.not(public_on: nil).order(public_on: :desc) }
13
+
14
+ def self.public_on(time = Time.current)
15
+ where("#{table_name}.public_on <= :time AND " \
16
+ "(#{table_name}.public_until IS NULL " \
17
+ "OR #{table_name}.public_until >= :time)", time: time)
18
+ end
19
+
20
+ before_destroy :delete_elements
21
+
22
+ # Determines if this version is public
23
+ #
24
+ # Takes the two timestamps +public_on+ and +public_until+
25
+ # and returns true if the time given (+Time.current+ per default)
26
+ # is in this timespan.
27
+ #
28
+ # @param time [DateTime] (Time.current)
29
+ # @returns Boolean
30
+ def public?(time = Time.current)
31
+ already_public_for?(time) && still_public_for?(time)
32
+ end
33
+
34
+ # Determines if this version is already public for given time
35
+ # @param time [DateTime] (Time.current)
36
+ # @returns Boolean
37
+ def already_public_for?(time = Time.current)
38
+ !public_on.nil? && public_on <= time
39
+ end
40
+
41
+ # Determines if this version is still public for given time
42
+ # @param time [DateTime] (Time.current)
43
+ # @returns Boolean
44
+ def still_public_for?(time = Time.current)
45
+ public_until.nil? || public_until >= time
46
+ end
47
+
48
+ def element_repository
49
+ ElementsRepository.new(elements.includes({ contents: :essence }, :tags))
50
+ end
51
+
52
+ private
53
+
54
+ def delete_elements
55
+ DeleteElements.new(elements).call
56
+ end
57
+ end
58
+ end
@@ -27,15 +27,9 @@ module Alchemy
27
27
  # Given a string with an x, this function returns a Hash with point
28
28
  # :width and :height.
29
29
  #
30
- def sizes_from_string(string = "0x0")
31
- string = "0x0" if string.nil? || string.empty?
30
+ def sizes_from_string(string)
31
+ width, height = string.to_s.split("x", 2).map(&:to_i)
32
32
 
33
- raise ArgumentError unless string =~ /(\d*x\d*)/
34
-
35
- width, height = string.scan(/(\d*)x(\d*)/)[0].map(&:to_i)
36
-
37
- width = 0 if width.nil?
38
- height = 0 if height.nil?
39
33
  {
40
34
  width: width,
41
35
  height: height,
@@ -16,6 +16,8 @@ module Alchemy
16
16
  def call
17
17
  max_image_size = Alchemy::Config.get(:preprocess_image_resize)
18
18
  image_file.thumb!(max_image_size) if max_image_size.present?
19
+ # Auto orient the image so EXIF orientation data is taken into account
20
+ image_file.auto_orient!
19
21
  end
20
22
 
21
23
  private
@@ -11,53 +11,16 @@ module Alchemy
11
11
  include Alchemy::Picture::Calculations
12
12
  end
13
13
 
14
- THUMBNAIL_WIDTH = 160
15
- THUMBNAIL_HEIGHT = 120
16
-
17
- # Returns the default centered image mask for a given size.
18
- # If the mask is bigger than the image, the mask is scaled down
19
- # so the largest possible part of the image is visible.
20
- #
21
- def default_mask(mask_arg)
22
- mask = mask_arg.dup
23
- mask[:width] = image_file_width if mask[:width].zero?
24
- mask[:height] = image_file_height if mask[:height].zero?
25
-
26
- crop_size = size_when_fitting({width: image_file_width, height: image_file_height}, mask)
27
- top_left = get_top_left_crop_corner(crop_size)
28
-
29
- point_and_mask_to_points(top_left, crop_size)
30
- end
31
-
32
- # Returns a size value String for the thumbnail used in essence picture editors.
33
- #
34
- def thumbnail_size(size_string = "0x0", crop = false)
35
- size = sizes_from_string(size_string)
36
-
37
- # only if crop is set do we need to actually parse the size string, otherwise
38
- # we take the base image size.
39
- if crop
40
- size[:width] = get_base_dimensions[:width] if size[:width].zero?
41
- size[:height] = get_base_dimensions[:height] if size[:height].zero?
42
- size = reduce_to_image(size)
43
- else
44
- size = get_base_dimensions
45
- end
46
-
47
- size = size_when_fitting({width: THUMBNAIL_WIDTH, height: THUMBNAIL_HEIGHT}, size)
48
- "#{size[:width]}x#{size[:height]}"
49
- end
50
-
51
14
  # Returns the rendered cropped image. Tries to use the crop_from and crop_size
52
15
  # parameters. When they can't be parsed, it just crops from the center.
53
16
  #
54
17
  def crop(size, crop_from = nil, crop_size = nil, upsample = false)
55
18
  raise "No size given!" if size.empty?
56
19
 
57
- render_to = sizes_from_string(size)
20
+ render_to = inferred_sizes_from_string(size)
58
21
  if crop_from && crop_size
59
22
  top_left = point_from_string(crop_from)
60
- crop_dimensions = sizes_from_string(crop_size)
23
+ crop_dimensions = inferred_sizes_from_string(crop_size)
61
24
  xy_crop_resize(render_to, top_left, crop_dimensions, upsample)
62
25
  else
63
26
  center_crop(render_to, upsample)
@@ -75,21 +38,30 @@ module Alchemy
75
38
  def landscape_format?
76
39
  image_file.landscape?
77
40
  end
41
+
78
42
  alias_method :landscape?, :landscape_format?
43
+ deprecate landscape_format?: "Use image_file.landscape? instead", deprecator: Alchemy::Deprecation
44
+ deprecate landscape?: "Use image_file.landscape? instead", deprecator: Alchemy::Deprecation
79
45
 
80
46
  # Returns true if picture's width is smaller than it's height
81
47
  #
82
48
  def portrait_format?
83
49
  image_file.portrait?
84
50
  end
51
+
85
52
  alias_method :portrait?, :portrait_format?
53
+ deprecate portrait_format?: "Use image_file.portrait? instead", deprecator: Alchemy::Deprecation
54
+ deprecate portrait?: "Use image_file.portrait? instead", deprecator: Alchemy::Deprecation
86
55
 
87
56
  # Returns true if picture's width and height is equal
88
57
  #
89
58
  def square_format?
90
59
  image_file.aspect_ratio == 1.0
91
60
  end
61
+
92
62
  alias_method :square?, :square_format?
63
+ deprecate square_format?: "Use image_file.aspect_ratio instead", deprecator: Alchemy::Deprecation
64
+ deprecate square?: "Use image_file.aspect_ratio instead", deprecator: Alchemy::Deprecation
93
65
 
94
66
  # Returns true if the class we're included in has a meaningful render_size attribute
95
67
  #
@@ -121,62 +93,18 @@ module Alchemy
121
93
  }
122
94
  end
123
95
 
124
- # Given dimensions for a possibly destructive crop operation,
125
- # this function returns the top left corner as a Hash
126
- # with keys :x, :y
127
- #
128
- def get_top_left_crop_corner(dimensions)
129
- {
130
- x: (image_file_width - dimensions[:width]) / 2,
131
- y: (image_file_height - dimensions[:height]) / 2,
132
- }
133
- end
96
+ def inferred_sizes_from_string(string)
97
+ sizes = sizes_from_string(string)
98
+ ratio = image_file_width.to_f / image_file_height
134
99
 
135
- # Gets the base dimensions (the dimensions of the Picture before scaling).
136
- # If anything is missing, it gets padded with zero (Integer 0).
137
- # This is the order of precedence: crop_size > image_size
138
- def get_base_dimensions
139
- if crop_size?
140
- sizes_from_string(crop_size)
141
- else
142
- image_size
100
+ if sizes[:width].zero?
101
+ sizes[:width] = image_file_width * ratio
143
102
  end
144
- end
145
-
146
- # This function takes a target and a base dimensions hash and returns
147
- # the dimensions of the image when the base dimensions hash fills
148
- # the target.
149
- #
150
- # Aspect ratio will be preserved.
151
- #
152
- def size_when_fitting(target, dimensions = get_base_dimensions)
153
- zoom = [
154
- dimensions[:width].to_f / target[:width],
155
- dimensions[:height].to_f / target[:height],
156
- ].max
157
-
158
- if zoom == 0.0
159
- width = target[:width]
160
- height = target[:height]
161
- else
162
- width = (dimensions[:width] / zoom).round
163
- height = (dimensions[:height] / zoom).round
103
+ if sizes[:height].zero?
104
+ sizes[:height] = image_file_width / ratio
164
105
  end
165
106
 
166
- {width: width.to_i, height: height.to_i}
167
- end
168
-
169
- # Given a point as a Hash with :x and :y, and a mask with
170
- # :width and :height, this function returns the area on the
171
- # underlying canvas as a Hash of two points
172
- #
173
- def point_and_mask_to_points(point, mask)
174
- {
175
- x1: point[:x],
176
- y1: point[:y],
177
- x2: point[:x] + mask[:width],
178
- y2: point[:y] + mask[:height],
179
- }
107
+ sizes
180
108
  end
181
109
 
182
110
  # Converts a dimensions hash to a string of from "20x20"
@@ -197,20 +125,20 @@ module Alchemy
197
125
  # Use imagemagick to custom crop an image. Uses -thumbnail for better performance when resizing.
198
126
  #
199
127
  def xy_crop_resize(dimensions, top_left, crop_dimensions, upsample)
200
- crop_argument = "-crop #{dimensions_to_string(crop_dimensions)}"
128
+ crop_argument = dimensions_to_string(crop_dimensions)
201
129
  crop_argument += "+#{top_left[:x]}+#{top_left[:y]}"
202
130
 
203
- resize_argument = "-resize #{dimensions_to_string(dimensions)}"
131
+ resize_argument = dimensions_to_string(dimensions)
204
132
  resize_argument += ">" unless upsample
205
- image_file.convert "#{crop_argument} #{resize_argument}"
133
+ image_file.crop_resize(crop_argument, resize_argument)
206
134
  end
207
135
 
208
136
  # Used when centercropping.
209
137
  #
210
138
  def reduce_to_image(dimensions)
211
139
  {
212
- width: [dimensions[:width], image_file_width].min,
213
- height: [dimensions[:height], image_file_height].min,
140
+ width: [dimensions[:width].to_i, image_file_width.to_i].min,
141
+ height: [dimensions[:height].to_i, image_file_height.to_i].min,
214
142
  }
215
143
  end
216
144
  end
@@ -86,7 +86,9 @@ module Alchemy
86
86
  dragonfly_accessor :image_file, app: :alchemy_pictures do
87
87
  # Preprocess after uploading the picture
88
88
  after_assign do |image|
89
- self.class.preprocessor_class.new(image).call
89
+ if has_convertible_format?
90
+ self.class.preprocessor_class.new(image).call
91
+ end
90
92
  end
91
93
  end
92
94
 
@@ -108,7 +110,7 @@ module Alchemy
108
110
  case_sensitive: false,
109
111
  message: Alchemy.t("not a valid image")
110
112
 
111
- stampable stamper_class_name: Alchemy.user_class.name
113
+ stampable stamper_class_name: Alchemy.user_class_name
112
114
 
113
115
  scope :named, ->(name) { where("#{table_name}.name LIKE ?", "%#{name}%") }
114
116
  scope :recent, -> { where("#{table_name}.created_at > ?", Time.current - 24.hours).order(:created_at) }
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ # Picture thumbnails and cropping concerns
5
+ module PictureThumbnails
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ before_save :fix_crop_values
10
+
11
+ delegate :image_file_width, :image_file_height, :image_file, to: :picture, allow_nil: true
12
+ end
13
+
14
+ # The url to show the picture.
15
+ #
16
+ # Takes all values like +name+ and crop sizes (+crop_from+, +crop_size+ from the build in graphical image cropper)
17
+ # and also adds the security token.
18
+ #
19
+ # You typically want to set the size the picture should be resized to.
20
+ #
21
+ # === Example:
22
+ #
23
+ # essence_picture.picture_url(size: '200x300', crop: true, format: 'gif')
24
+ # # '/pictures/1/show/200x300/crop/cats.gif?sh=765rfghj'
25
+ #
26
+ # @option options size [String]
27
+ # The size the picture should be resized to.
28
+ #
29
+ # @option options format [String]
30
+ # The format the picture should be rendered in.
31
+ # Defaults to the +image_output_format+ from the +Alchemy::Config+.
32
+ #
33
+ # @option options crop [Boolean]
34
+ # If set to true the picture will be cropped to fit the size value.
35
+ #
36
+ # @return [String]
37
+ def picture_url(options = {})
38
+ return if picture.nil?
39
+
40
+ picture.url(picture_url_options.merge(options)) || "missing-image.png"
41
+ end
42
+
43
+ # Picture rendering options
44
+ #
45
+ # Returns the +default_render_format+ of the associated +Alchemy::Picture+
46
+ # together with the +crop_from+ and +crop_size+ values
47
+ #
48
+ # @return [HashWithIndifferentAccess]
49
+ def picture_url_options
50
+ return {} if picture.nil?
51
+
52
+ crop = !!settings[:crop]
53
+
54
+ {
55
+ format: picture.default_render_format,
56
+ crop: crop,
57
+ crop_from: crop && crop_from.presence || nil,
58
+ crop_size: crop && crop_size.presence || nil,
59
+ size: settings[:size],
60
+ }.with_indifferent_access
61
+ end
62
+
63
+ # Returns an url for the thumbnail representation of the assigned picture
64
+ #
65
+ # It takes cropping values into account, so it always represents the current
66
+ # image displayed in the frontend.
67
+ #
68
+ # @return [String]
69
+ def thumbnail_url
70
+ return if picture.nil?
71
+
72
+ picture.url(thumbnail_url_options) || "alchemy/missing-image.svg"
73
+ end
74
+
75
+ # Thumbnail rendering options
76
+ #
77
+ # @return [HashWithIndifferentAccess]
78
+ def thumbnail_url_options
79
+ crop = !!settings[:crop]
80
+
81
+ {
82
+ size: "160x120",
83
+ crop: crop,
84
+ crop_from: crop && crop_from.presence || default_crop_from&.join("x"),
85
+ crop_size: crop && crop_size.presence || default_crop_size&.join("x"),
86
+ flatten: true,
87
+ format: picture&.image_file_format || "jpg",
88
+ }
89
+ end
90
+
91
+ # Settings for the graphical JS image cropper
92
+ def image_cropper_settings
93
+ Alchemy::ImageCropperSettings.new(
94
+ render_size: dimensions_from_string(render_size.presence || settings[:size]),
95
+ default_crop_from: default_crop_from,
96
+ default_crop_size: default_crop_size,
97
+ fixed_ratio: settings[:fixed_ratio],
98
+ image_width: picture&.image_file_width,
99
+ image_height: picture&.image_file_height,
100
+ ).to_h
101
+ end
102
+
103
+ # Show image cropping link for content
104
+ def allow_image_cropping?
105
+ settings[:crop] && picture &&
106
+ picture.can_be_cropped_to?(
107
+ settings[:size],
108
+ settings[:upsample],
109
+ ) && !!picture.image_file
110
+ end
111
+
112
+ private
113
+
114
+ def default_crop_size
115
+ return nil unless settings[:crop] && settings[:size]
116
+
117
+ mask = inferred_dimensions_from_string(settings[:size])
118
+ zoom = thumbnail_zoom_factor(mask)
119
+ return nil if zoom.zero?
120
+
121
+ [(mask[0] / zoom), (mask[1] / zoom)].map(&:round)
122
+ end
123
+
124
+ def thumbnail_zoom_factor(mask)
125
+ [
126
+ mask[0].to_f / (image_file_width || 1),
127
+ mask[1].to_f / (image_file_height || 1),
128
+ ].max
129
+ end
130
+
131
+ def default_crop_from
132
+ return nil unless settings[:crop]
133
+ return nil if default_crop_size.nil?
134
+
135
+ [
136
+ ((image_file_width || 0) - default_crop_size[0]) / 2,
137
+ ((image_file_height || 0) - default_crop_size[1]) / 2,
138
+ ].map(&:round)
139
+ end
140
+
141
+ def dimensions_from_string(string)
142
+ return if string.nil?
143
+
144
+ string.split("x", 2).map(&:to_i)
145
+ end
146
+
147
+ def inferred_dimensions_from_string(string)
148
+ return if string.nil?
149
+
150
+ width, height = dimensions_from_string(string)
151
+ ratio = image_file_width.to_f / image_file_height.to_i
152
+
153
+ if width.zero? && ratio.is_a?(Float)
154
+ width = height * ratio
155
+ end
156
+
157
+ if height.zero? && ratio.is_a?(Float)
158
+ height = width / ratio
159
+ end
160
+
161
+ [width.to_i, height.to_i]
162
+ end
163
+
164
+ def fix_crop_values
165
+ %i[crop_from crop_size].each do |crop_value|
166
+ if public_send(crop_value).is_a?(String)
167
+ public_send("#{crop_value}=", normalize_crop_value(crop_value))
168
+ end
169
+ end
170
+ end
171
+
172
+ def normalize_crop_value(crop_value)
173
+ public_send(crop_value).split("x").map { |n| normalize_number(n) }.join("x")
174
+ end
175
+
176
+ def normalize_number(number)
177
+ number = number.to_f.round
178
+ number.negative? ? 0 : number
179
+ end
180
+ end
181
+ end