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
@@ -23,6 +23,8 @@
23
23
 
24
24
  module Alchemy
25
25
  class EssencePicture < BaseRecord
26
+ include Alchemy::PictureThumbnails
27
+
26
28
  acts_as_essence ingredient_column: :picture, belongs_to: {
27
29
  class_name: "Alchemy::Picture",
28
30
  foreign_key: :picture_id,
@@ -30,81 +32,9 @@ module Alchemy
30
32
  optional: true,
31
33
  }
32
34
 
33
- delegate :image_file_width, :image_file_height, :image_file, to: :picture
34
- before_save :fix_crop_values
35
- before_save :replace_newlines
36
-
37
- include Alchemy::Picture::Transformations
38
-
39
- # The url to show the picture.
40
- #
41
- # Takes all values like +name+ and crop sizes (+crop_from+, +crop_size+ from the build in graphical image cropper)
42
- # and also adds the security token.
43
- #
44
- # You typically want to set the size the picture should be resized to.
45
- #
46
- # === Example:
47
- #
48
- # essence_picture.picture_url(size: '200x300', crop: true, format: 'gif')
49
- # # '/pictures/1/show/200x300/crop/cats.gif?sh=765rfghj'
50
- #
51
- # @option options size [String]
52
- # The size the picture should be resized to.
53
- #
54
- # @option options format [String]
55
- # The format the picture should be rendered in.
56
- # Defaults to the +image_output_format+ from the +Alchemy::Config+.
57
- #
58
- # @option options crop [Boolean]
59
- # If set to true the picture will be cropped to fit the size value.
60
- #
61
- # @return [String]
62
- def picture_url(options = {})
63
- return if picture.nil?
64
-
65
- picture.url(picture_url_options.merge(options)) || "missing-image.png"
66
- end
35
+ delegate :settings, to: :content
67
36
 
68
- # Picture rendering options
69
- #
70
- # Returns the +default_render_format+ of the associated +Alchemy::Picture+
71
- # together with the +crop_from+ and +crop_size+ values
72
- #
73
- # @return [HashWithIndifferentAccess]
74
- def picture_url_options
75
- return {} if picture.nil?
76
-
77
- {
78
- format: picture.default_render_format,
79
- crop_from: crop_from.presence,
80
- crop_size: crop_size.presence,
81
- size: content.settings[:size],
82
- }.with_indifferent_access
83
- end
84
-
85
- # Returns an url for the thumbnail representation of the assigned picture
86
- #
87
- # It takes cropping values into account, so it always represents the current
88
- # image displayed in the frontend.
89
- #
90
- # @return [String]
91
- def thumbnail_url
92
- return if picture.nil?
93
-
94
- crop = crop_values_present? || content.settings[:crop]
95
- size = render_size || content.settings[:size]
96
-
97
- options = {
98
- size: thumbnail_size(size, crop),
99
- crop: !!crop,
100
- crop_from: crop_from.presence,
101
- crop_size: crop_size.presence,
102
- flatten: true,
103
- format: picture.image_file_format,
104
- }
105
-
106
- picture.url(options) || "alchemy/missing-image.svg"
107
- end
37
+ before_save :replace_newlines
108
38
 
109
39
  # The name of the picture used as preview text in element editor views.
110
40
  #
@@ -118,18 +48,6 @@ module Alchemy
118
48
  picture.name.to_s[0..max - 1]
119
49
  end
120
50
 
121
- # A Hash of coordinates suitable for the graphical image cropper.
122
- #
123
- # @return [Hash]
124
- def cropping_mask
125
- return if crop_from.blank? || crop_size.blank?
126
-
127
- crop_from = point_from_string(read_attribute(:crop_from))
128
- crop_size = sizes_from_string(read_attribute(:crop_size))
129
-
130
- point_and_mask_to_points(crop_from, crop_size)
131
- end
132
-
133
51
  # Returns a serialized ingredient value for json api
134
52
  #
135
53
  # @return [String]
@@ -137,38 +55,8 @@ module Alchemy
137
55
  picture_url(content.settings)
138
56
  end
139
57
 
140
- # Show image cropping link for content
141
- def allow_image_cropping?
142
- content && content.settings[:crop] && picture &&
143
- picture.can_be_cropped_to?(
144
- content.settings[:size],
145
- content.settings[:upsample],
146
- ) && !!picture.image_file
147
- end
148
-
149
- def crop_values_present?
150
- crop_from.present? && crop_size.present?
151
- end
152
-
153
58
  private
154
59
 
155
- def fix_crop_values
156
- %i(crop_from crop_size).each do |crop_value|
157
- if self[crop_value].is_a?(String)
158
- write_attribute crop_value, normalize_crop_value(crop_value)
159
- end
160
- end
161
- end
162
-
163
- def normalize_crop_value(crop_value)
164
- self[crop_value].split("x").map { |n| normalize_number(n) }.join("x")
165
- end
166
-
167
- def normalize_number(number)
168
- number = number.to_f.round
169
- number.negative? ? 0 : number
170
- end
171
-
172
60
  def replace_newlines
173
61
  return nil if caption.nil?
174
62
 
@@ -17,6 +17,7 @@ module Alchemy
17
17
  acts_as_essence preview_text_column: "stripped_body"
18
18
 
19
19
  before_save :strip_content
20
+ before_save :sanitize_content
20
21
 
21
22
  def has_tinymce?
22
23
  true
@@ -27,5 +28,16 @@ module Alchemy
27
28
  def strip_content
28
29
  self.stripped_body = Rails::Html::FullSanitizer.new.sanitize(body)
29
30
  end
31
+
32
+ def sanitize_content
33
+ self.sanitized_body = Rails::Html::SafeListSanitizer.new.sanitize(
34
+ body,
35
+ content_sanitizer_settings
36
+ )
37
+ end
38
+
39
+ def content_sanitizer_settings
40
+ content&.settings&.fetch(:sanitizer, {}) || {}
41
+ end
30
42
  end
31
43
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ class EssenceVideo < ActiveRecord::Base
5
+ acts_as_essence(
6
+ ingredient_column: :attachment,
7
+ preview_text_method: :name,
8
+ )
9
+
10
+ belongs_to :attachment, optional: true
11
+ end
12
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ # Settings for the graphical JS image cropper
5
+ class ImageCropperSettings
6
+ attr_reader :render_size, :default_crop_from, :default_crop_size, :fixed_ratio, :image_width, :image_height
7
+
8
+ def initialize(render_size:, default_crop_from:, default_crop_size:, fixed_ratio:, image_width:, image_height:)
9
+ @render_size = render_size || [0, 0]
10
+ @fixed_ratio = fixed_ratio
11
+ @image_width = image_width.to_i
12
+ @image_height = image_height.to_i
13
+ @default_crop_from = default_crop_from || [0, 0]
14
+ @default_crop_size = default_crop_size || [@image_width, @image_height]
15
+ end
16
+
17
+ def to_h
18
+ return {} if image_width.zero? || image_height.zero?
19
+
20
+ {
21
+ min_size: large_enough? ? min_size : false,
22
+ ratio: ratio,
23
+ default_box: default_box,
24
+ image_size: [image_width, image_height],
25
+ }.freeze
26
+ end
27
+
28
+ def [](key)
29
+ to_h[key]
30
+ end
31
+
32
+ private
33
+
34
+ def ratio
35
+ return false if fixed_ratio == false
36
+ return ratio_from_size if fixed_ratio.nil?
37
+
38
+ Float(fixed_ratio)
39
+ end
40
+
41
+ # Only returns an array of width and height if image is large enough
42
+ # or false to disable min size option of the image cropper
43
+ def large_enough?
44
+ return true if render_size.any?(&:zero?)
45
+
46
+ image_width >= render_size[0] && image_height >= render_size[1]
47
+ end
48
+
49
+ # Infers the aspect ratio from size or fixed_ratio. If you don't want a fixed
50
+ # aspect ratio, don't specify a size or only width or height.
51
+ #
52
+ def ratio_from_size
53
+ if render_size.none?(&:zero?)
54
+ render_size[0].to_f / render_size[1]
55
+ elsif [image_width, image_height].none?(&:zero?)
56
+ image_width.to_f / image_height
57
+ else
58
+ false
59
+ end
60
+ end
61
+
62
+ # Infers the minimum width or height
63
+ # if the aspect ratio and one dimension is specified.
64
+ #
65
+ def min_size
66
+ return render_size unless ratio
67
+
68
+ if render_size[1].zero?
69
+ [render_size[0], (render_size[0] / ratio).to_i]
70
+ else
71
+ [(render_size[1] * ratio).to_i, render_size[1]]
72
+ end
73
+ end
74
+
75
+ # Given a point and a mask, this function returns the area on the
76
+ # underlying canvas as a Hash of two points
77
+ #
78
+ def default_box
79
+ [
80
+ default_crop_from[0],
81
+ default_crop_from[1],
82
+ default_crop_from[0] + default_crop_size[0],
83
+ default_crop_from[1] + default_crop_size[1],
84
+ ]
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ class Ingredient < BaseRecord
5
+ class DefinitionError < StandardError; end
6
+
7
+ include Hints
8
+
9
+ self.abstract_class = true
10
+ self.table_name = "alchemy_ingredients"
11
+
12
+ belongs_to :element, touch: true, class_name: "Alchemy::Element", inverse_of: :ingredients
13
+ belongs_to :related_object, polymorphic: true, optional: true
14
+
15
+ validates :type, presence: true
16
+ validates :role, presence: true
17
+
18
+ validates_with Alchemy::IngredientValidator, on: :update, if: :has_validations?
19
+
20
+ scope :audios, -> { where(type: "Alchemy::Ingredients::Audio") }
21
+ scope :booleans, -> { where(type: "Alchemy::Ingredients::Boolean") }
22
+ scope :datetimes, -> { where(type: "Alchemy::Ingredients::Datetime") }
23
+ scope :files, -> { where(type: "Alchemy::Ingredients::File") }
24
+ scope :headlines, -> { where(type: "Alchemy::Ingredients::Headline") }
25
+ scope :htmls, -> { where(type: "Alchemy::Ingredients::Html") }
26
+ scope :links, -> { where(type: "Alchemy::Ingredients::Link") }
27
+ scope :nodes, -> { where(type: "Alchemy::Ingredients::Node") }
28
+ scope :pages, -> { where(type: "Alchemy::Ingredients::Page") }
29
+ scope :pictures, -> { where(type: "Alchemy::Ingredients::Picture") }
30
+ scope :richtexts, -> { where(type: "Alchemy::Ingredients::Richtext") }
31
+ scope :selects, -> { where(type: "Alchemy::Ingredients::Select") }
32
+ scope :texts, -> { where(type: "Alchemy::Ingredients::Text") }
33
+ scope :videos, -> { where(type: "Alchemy::Ingredients::Video") }
34
+
35
+ class << self
36
+ # Builds concrete ingredient class as described in the +elements.yml+
37
+ def build(attributes = {})
38
+ element = attributes[:element]
39
+ raise ArgumentError, "No element given. Please pass element in attributes." if element.nil?
40
+ raise ArgumentError, "No role given. Please pass role in attributes." if attributes[:role].nil?
41
+
42
+ definition = element.ingredient_definition_for(attributes[:role])
43
+ if definition.nil?
44
+ raise DefinitionError,
45
+ "No definition found for #{attributes[:role]}. Please define #{attributes[:role]} on #{element[:name]}."
46
+ end
47
+
48
+ ingredient_class = Ingredient.ingredient_class_by_type(definition[:type])
49
+ ingredient_class.new(
50
+ type: Ingredient.normalize_type(definition[:type]),
51
+ value: default_value(definition),
52
+ role: definition[:role],
53
+ element: element,
54
+ )
55
+ end
56
+
57
+ # Creates concrete ingredient class as described in the +elements.yml+
58
+ def create(attributes = {})
59
+ build(attributes).tap(&:save)
60
+ end
61
+
62
+ # Defines getter and setter method aliases for related object
63
+ #
64
+ # @param [String|Symbol] The name of the alias
65
+ # @param [String] The class name of the related object
66
+ def related_object_alias(name, class_name:)
67
+ alias_method name, :related_object
68
+ alias_method "#{name}=", :related_object=
69
+
70
+ # Somehow Rails STI does not allow us to use `alias_method` for the related_object_id
71
+ define_method "#{name}_id" do
72
+ related_object_id
73
+ end
74
+
75
+ define_method "#{name}_id=" do |id|
76
+ self.related_object_id = id
77
+ self.related_object_type = class_name
78
+ end
79
+ end
80
+
81
+ # Returns an ingredient class by type
82
+ #
83
+ # Raises ArgumentError if there is no such class in the
84
+ # +Alchemy::Ingredients+ module namespace.
85
+ #
86
+ # If you add custom ingredient class,
87
+ # put them in the +Alchemy::Ingredients+ module namespace
88
+ #
89
+ # @param [String] The ingredient class name to constantize
90
+ # @return [Class]
91
+ def ingredient_class_by_type(ingredient_type)
92
+ Alchemy::Ingredients.const_get(ingredient_type.to_s.classify.demodulize)
93
+ end
94
+
95
+ # Modulize ingredient type
96
+ #
97
+ # Makes sure the passed ingredient type is in the +Alchemy::Ingredients+
98
+ # module namespace.
99
+ #
100
+ # If you add custom ingredient class,
101
+ # put them in the +Alchemy::Ingredients+ module namespace
102
+ # @param [String] Ingredient class name
103
+ # @return [String]
104
+ def normalize_type(ingredient_type)
105
+ "Alchemy::Ingredients::#{ingredient_type.to_s.classify.demodulize}"
106
+ end
107
+
108
+ def translated_label_for(role, element_name = nil)
109
+ Alchemy.t(
110
+ role,
111
+ scope: "ingredient_roles.#{element_name}",
112
+ default: Alchemy.t("ingredient_roles.#{role}", default: role.humanize),
113
+ )
114
+ end
115
+
116
+ private
117
+
118
+ # Returns the default value from ingredient definition
119
+ #
120
+ # If the value is a symbol it gets passed through i18n
121
+ # inside the +alchemy.default_ingredient_texts+ scope
122
+ def default_value(definition)
123
+ default = definition[:default]
124
+ case default
125
+ when Symbol
126
+ Alchemy.t(default, scope: :default_ingredient_texts)
127
+ else
128
+ default
129
+ end
130
+ end
131
+ end
132
+
133
+ # Compatibility method for access from element
134
+ def essence
135
+ self
136
+ end
137
+
138
+ # The value or the related object if present
139
+ def value
140
+ related_object || self[:value]
141
+ end
142
+
143
+ # Settings for this ingredient from the +elements.yml+ definition.
144
+ def settings
145
+ definition[:settings] || {}
146
+ end
147
+
148
+ # Fetches value from settings
149
+ #
150
+ # @param key [Symbol] - The hash key you want to fetch the value from
151
+ # @param options [Hash] - An optional Hash that can override the settings.
152
+ # Normally passed as options hash into the content
153
+ # editor view.
154
+ def settings_value(key, options = {})
155
+ settings.merge(options || {})[key.to_sym]
156
+ end
157
+
158
+ # Definition hash for this ingredient from +elements.yml+ file.
159
+ #
160
+ def definition
161
+ return {} unless element
162
+
163
+ element.ingredient_definition_for(role) || {}
164
+ end
165
+
166
+ # The first 30 characters of the value
167
+ #
168
+ # Used by the Element#preview_text method.
169
+ #
170
+ # @param [Integer] max_length (30)
171
+ #
172
+ def preview_text(maxlength = 30)
173
+ value.to_s[0..maxlength - 1]
174
+ end
175
+
176
+ # Cross DB adapter data accessor that works
177
+ def data
178
+ @_data ||= (self[:data] || {}).with_indifferent_access
179
+ end
180
+
181
+ # The path to the view partial of the ingredient
182
+ # @return [String]
183
+ def to_partial_path
184
+ "alchemy/ingredients/#{partial_name}_view"
185
+ end
186
+
187
+ # The demodulized underscored class name of the ingredient
188
+ # @return [String]
189
+ def partial_name
190
+ self.class.name.demodulize.underscore
191
+ end
192
+
193
+ # @return [Boolean]
194
+ def has_validations?
195
+ !!definition[:validate]
196
+ end
197
+
198
+ # @return [Boolean]
199
+ def has_hint?
200
+ !!definition[:hint]
201
+ end
202
+
203
+ # @return [Boolean]
204
+ def deprecated?
205
+ !!definition[:deprecated]
206
+ end
207
+
208
+ # @return [Boolean]
209
+ def has_tinymce?
210
+ false
211
+ end
212
+
213
+ private
214
+
215
+ def hint_translation_attribute
216
+ role
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ # Ingredient Validations:
5
+ #
6
+ # Ingredient validations can be set inside the +config/elements.yml+ file.
7
+ #
8
+ # Supported validations are:
9
+ #
10
+ # * presence
11
+ # * uniqueness
12
+ # * format
13
+ #
14
+ # *) format needs to come with a regex or a predefined matcher string as its value.
15
+ #
16
+ # There are already predefined format matchers listed in the +config/alchemy/config.yml+ file.
17
+ # It is also possible to add own format matchers there.
18
+ #
19
+ # Example of format matchers in +config/alchemy/config.yml+:
20
+ #
21
+ # format_matchers:
22
+ # email: !ruby/regexp '/\A[^@\s]+@([^@\s]+\.)+[^@\s]+\z/'
23
+ # url: !ruby/regexp '/\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?\z/ix'
24
+ # ssl: !ruby/regexp '/https:\/\/[\S]+/'
25
+ #
26
+ # Example of an element definition with ingredient validations:
27
+ #
28
+ # - name: person
29
+ # ingredients:
30
+ # - role: name
31
+ # type: Text
32
+ # validate: [presence]
33
+ # - role: email
34
+ # type: Text
35
+ # validate: [format: 'email']
36
+ # - role: homepage
37
+ # type: Text
38
+ # validate: [format: !ruby/regexp '^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$']
39
+ #
40
+ # Example of an element definition with chained validations.
41
+ #
42
+ # - name: person
43
+ # ingredients:
44
+ # - role: name
45
+ # type: Text
46
+ # validate: [presence, uniqueness, format: 'name']
47
+ #
48
+ class IngredientValidator < ActiveModel::Validator
49
+ def validate(ingredient)
50
+ @ingredient = ingredient
51
+ validations.each do |validation|
52
+ if validation.respond_to?(:keys)
53
+ validation.map do |key, value|
54
+ send("validate_#{key}", value)
55
+ end
56
+ else
57
+ send("validate_#{validation}")
58
+ end
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ attr_reader :ingredient
65
+
66
+ def validations
67
+ ingredient.definition.fetch(:validate, [])
68
+ end
69
+
70
+ def validate_presence(*)
71
+ if ingredient.value.blank?
72
+ ingredient.errors.add(:value, :blank)
73
+ end
74
+ end
75
+
76
+ def validate_uniqueness(*)
77
+ if duplicates.any?
78
+ ingredient.errors.add(:value, :taken)
79
+ end
80
+ end
81
+
82
+ def validate_format(format)
83
+ matcher = Alchemy::Config.get("format_matchers")[format] || format
84
+ if !ingredient.value.to_s.match?(Regexp.new(matcher))
85
+ ingredient.errors.add(:value, :invalid)
86
+ end
87
+ end
88
+
89
+ def duplicates
90
+ ingredient.class
91
+ .joins(:element).merge(Alchemy::Element.available)
92
+ .where(Alchemy::Element.table_name => { name: ingredient.element.name })
93
+ .where(value: ingredient.value)
94
+ .where.not(id: ingredient.id)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ module Ingredients
5
+ # A audio attachment
6
+ #
7
+ class Audio < Alchemy::Ingredient
8
+ store_accessor :data,
9
+ :autoplay,
10
+ :controls,
11
+ :muted,
12
+ :loop
13
+
14
+ related_object_alias :attachment, class_name: "Alchemy::Attachment"
15
+
16
+ delegate :name, to: :attachment, allow_nil: true
17
+
18
+ # The first 30 characters of the attachments name
19
+ #
20
+ # Used by the Element#preview_text method.
21
+ #
22
+ # @param [Integer] max_length (30)
23
+ #
24
+ def preview_text(max_length = 30)
25
+ name.to_s[0..max_length - 1]
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ module Ingredients
5
+ # A boolean value
6
+ #
7
+ class Boolean < Alchemy::Ingredient
8
+ def value
9
+ ActiveRecord::Type::Boolean.new.cast(self[:value])
10
+ end
11
+
12
+ # The localized value
13
+ #
14
+ # Used by the Element#preview_text method.
15
+ #
16
+ def preview_text(_max_length = nil)
17
+ Alchemy.t(value, scope: "ingredient_values.boolean")
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ module Ingredients
5
+ # A datetime value
6
+ #
7
+ class Datetime < Alchemy::Ingredient
8
+ def value
9
+ ActiveRecord::Type::DateTime.new.cast(self[:value])
10
+ end
11
+
12
+ # Returns localized date for the Element#preview_text method.
13
+ def preview_text(_maxlength = nil)
14
+ return "" unless value
15
+
16
+ ::I18n.l(value, format: :'alchemy.essence_date')
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ module Ingredients
5
+ # A link to a file
6
+ #
7
+ # Attach Alchemy::Attachment into this ingredient
8
+ #
9
+ class File < Alchemy::Ingredient
10
+ store_accessor :data,
11
+ :css_class,
12
+ :link_text,
13
+ :title
14
+
15
+ related_object_alias :attachment, class_name: "Alchemy::Attachment"
16
+
17
+ delegate :name, to: :attachment, allow_nil: true
18
+
19
+ # The first 30 characters of the attachments name
20
+ #
21
+ # Used by the Element#preview_text method.
22
+ #
23
+ # @param [Integer] max_length (30)
24
+ #
25
+ def preview_text(max_length = 30)
26
+ attachment&.name.to_s[0..max_length - 1]
27
+ end
28
+ end
29
+ end
30
+ end