alchemy_cms 8.0.11 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -10
  3. data/app/assets/builds/alchemy/admin.css +1 -1
  4. data/app/assets/builds/alchemy/dark-theme.css +1 -1
  5. data/app/assets/builds/alchemy/light-theme.css +1 -1
  6. data/app/assets/builds/alchemy/preview.min.js +1 -1
  7. data/app/assets/builds/alchemy/theme.css +1 -1
  8. data/app/{views/alchemy/admin/elements/_element.html.erb → components/alchemy/admin/element_editor.html.erb} +34 -29
  9. data/app/components/alchemy/admin/element_editor.rb +115 -0
  10. data/app/components/alchemy/admin/element_select.rb +12 -9
  11. data/app/components/alchemy/admin/ingredient_editor.rb +54 -0
  12. data/app/components/alchemy/admin/list_filter.rb +16 -5
  13. data/app/components/alchemy/admin/page_node.html.erb +214 -0
  14. data/app/components/alchemy/admin/page_node.rb +70 -0
  15. data/app/components/alchemy/admin/picture_thumbnail.rb +36 -0
  16. data/app/components/alchemy/admin/publish_page_button.html.erb +15 -0
  17. data/app/components/alchemy/admin/publish_page_button.rb +54 -0
  18. data/app/{helpers/alchemy/admin/tags_helper.rb → components/alchemy/admin/tags_list.rb} +19 -11
  19. data/app/components/alchemy/admin/toolbar_button.rb +17 -13
  20. data/app/components/alchemy/ingredients/audio_editor.rb +8 -0
  21. data/app/components/alchemy/ingredients/base_editor.rb +222 -0
  22. data/app/components/alchemy/ingredients/boolean_editor.rb +21 -0
  23. data/app/components/alchemy/ingredients/color_editor.rb +80 -0
  24. data/app/components/alchemy/ingredients/color_view.rb +13 -0
  25. data/app/components/alchemy/ingredients/datetime_editor.rb +28 -0
  26. data/app/components/alchemy/ingredients/file_editor.rb +69 -0
  27. data/app/components/alchemy/ingredients/headline_editor.rb +88 -0
  28. data/app/components/alchemy/ingredients/html_editor.rb +11 -0
  29. data/app/components/alchemy/ingredients/link_editor.rb +29 -0
  30. data/app/components/alchemy/ingredients/node_editor.rb +23 -0
  31. data/app/components/alchemy/ingredients/number_editor.rb +28 -0
  32. data/app/components/alchemy/ingredients/page_editor.rb +19 -0
  33. data/app/components/alchemy/ingredients/picture_editor.rb +81 -0
  34. data/app/components/alchemy/ingredients/richtext_editor.rb +31 -0
  35. data/app/components/alchemy/ingredients/select_editor.rb +37 -0
  36. data/app/components/alchemy/ingredients/select_view.rb +7 -0
  37. data/app/components/alchemy/ingredients/text_editor.rb +41 -0
  38. data/app/components/alchemy/ingredients/video_editor.rb +8 -0
  39. data/app/controllers/alchemy/admin/attachments_controller.rb +8 -6
  40. data/app/controllers/alchemy/admin/base_controller.rb +7 -18
  41. data/app/controllers/alchemy/admin/clipboard_controller.rb +15 -11
  42. data/app/controllers/alchemy/admin/dashboard_controller.rb +2 -2
  43. data/app/controllers/alchemy/admin/elements_controller.rb +34 -32
  44. data/app/controllers/alchemy/admin/ingredients_controller.rb +1 -0
  45. data/app/controllers/alchemy/admin/languages_controller.rb +0 -3
  46. data/app/controllers/alchemy/admin/layoutpages_controller.rb +2 -1
  47. data/app/controllers/alchemy/admin/legacy_page_urls_controller.rb +1 -1
  48. data/app/controllers/alchemy/admin/nodes_controller.rb +24 -1
  49. data/app/controllers/alchemy/admin/pages_controller.rb +36 -42
  50. data/app/controllers/alchemy/admin/pictures_controller.rb +2 -5
  51. data/app/controllers/alchemy/admin/resources_controller.rb +1 -1
  52. data/app/controllers/alchemy/api/ingredients_controller.rb +1 -1
  53. data/app/controllers/alchemy/api/pages_controller.rb +5 -3
  54. data/app/controllers/alchemy/base_controller.rb +6 -6
  55. data/app/controllers/alchemy/pages_controller.rb +12 -6
  56. data/app/controllers/concerns/alchemy/admin/archive_overlay.rb +0 -1
  57. data/app/controllers/concerns/alchemy/admin/clipboard.rb +57 -0
  58. data/app/controllers/concerns/alchemy/admin/uploader_responses.rb +2 -2
  59. data/app/controllers/concerns/alchemy/site_redirects.rb +1 -1
  60. data/app/decorators/alchemy/ingredient_editor.rb +37 -4
  61. data/app/helpers/alchemy/admin/base_helper.rb +10 -6
  62. data/app/helpers/alchemy/admin/ingredients_helper.rb +6 -3
  63. data/app/helpers/alchemy/base_helper.rb +1 -1
  64. data/app/helpers/alchemy/pages_helper.rb +1 -1
  65. data/app/javascript/alchemy_admin/components/action.js +5 -1
  66. data/app/javascript/alchemy_admin/components/color_select.js +73 -0
  67. data/app/javascript/alchemy_admin/components/element_editor/delete_element_button.js +11 -3
  68. data/app/javascript/alchemy_admin/components/element_editor/publish_element_button.js +7 -2
  69. data/app/javascript/alchemy_admin/components/element_editor.js +11 -12
  70. data/app/javascript/alchemy_admin/components/element_select.js +39 -17
  71. data/app/javascript/alchemy_admin/components/elements_window.js +0 -2
  72. data/app/javascript/alchemy_admin/components/file_editor.js +26 -0
  73. data/app/javascript/alchemy_admin/components/index.js +9 -0
  74. data/app/javascript/alchemy_admin/components/list_filter.js +57 -8
  75. data/app/javascript/alchemy_admin/components/message.js +9 -3
  76. data/app/javascript/alchemy_admin/components/page_node.js +119 -0
  77. data/app/javascript/alchemy_admin/{page_publication_fields.js → components/page_publication_fields.js} +9 -8
  78. data/app/javascript/alchemy_admin/{picture_editors.js → components/picture_editor.js} +30 -45
  79. data/app/javascript/alchemy_admin/components/picture_thumbnail.js +107 -0
  80. data/app/javascript/alchemy_admin/components/publish_page_button.js +41 -0
  81. data/app/javascript/alchemy_admin/components/select.js +3 -1
  82. data/app/javascript/alchemy_admin/components/sitemap.js +210 -0
  83. data/app/javascript/alchemy_admin/{sortable_elements.js → components/sortable_elements.js} +22 -25
  84. data/app/javascript/alchemy_admin/components/tinymce.js +10 -5
  85. data/app/javascript/alchemy_admin/components/uploader.js +30 -0
  86. data/app/javascript/alchemy_admin/image_overlay.js +0 -2
  87. data/app/javascript/alchemy_admin/initializer.js +0 -3
  88. data/app/javascript/alchemy_admin/link_dialog.js +1 -6
  89. data/app/javascript/alchemy_admin/templates/compiled.js +1 -1
  90. data/app/javascript/alchemy_admin/utils/ajax.js +15 -3
  91. data/app/javascript/alchemy_admin.js +0 -6
  92. data/app/models/alchemy/attachment.rb +4 -4
  93. data/app/models/alchemy/element/definitions.rb +1 -2
  94. data/app/models/alchemy/element/element_ingredients.rb +6 -2
  95. data/app/models/alchemy/element.rb +54 -13
  96. data/app/models/alchemy/element_definition.rb +4 -1
  97. data/app/models/alchemy/elements_repository.rb +6 -0
  98. data/app/models/alchemy/folded_page.rb +2 -2
  99. data/app/models/alchemy/ingredient.rb +38 -1
  100. data/app/models/alchemy/ingredient_definition.rb +4 -1
  101. data/app/models/alchemy/ingredient_validator.rb +6 -2
  102. data/app/models/alchemy/ingredients/color.rb +10 -0
  103. data/app/models/alchemy/ingredients/headline.rb +2 -17
  104. data/app/models/alchemy/ingredients/picture.rb +4 -4
  105. data/app/models/alchemy/ingredients/select.rb +19 -0
  106. data/app/models/alchemy/language/code.rb +0 -1
  107. data/app/models/alchemy/node.rb +28 -1
  108. data/app/models/alchemy/page/page_naming.rb +0 -7
  109. data/app/models/alchemy/page/page_natures.rb +7 -3
  110. data/app/models/alchemy/page/page_scopes.rb +13 -1
  111. data/app/models/alchemy/page/publisher.rb +14 -2
  112. data/app/models/alchemy/page.rb +102 -23
  113. data/app/models/alchemy/page_definition.rb +4 -1
  114. data/app/models/alchemy/page_version.rb +22 -6
  115. data/app/models/alchemy/picture.rb +10 -11
  116. data/app/models/alchemy/picture_variant.rb +1 -3
  117. data/app/models/alchemy/resource.rb +1 -1
  118. data/app/models/alchemy/storage_adapter/active_storage.rb +14 -2
  119. data/app/models/alchemy/storage_adapter/dragonfly.rb +12 -0
  120. data/app/models/alchemy/storage_adapter.rb +2 -0
  121. data/app/models/concerns/alchemy/picture_thumbnails.rb +4 -4
  122. data/app/models/concerns/alchemy/publishable.rb +54 -0
  123. data/app/serializers/alchemy/page_tree_serializer.rb +11 -31
  124. data/app/services/alchemy/copy_page.rb +17 -0
  125. data/app/services/alchemy/duplicate_element.rb +1 -1
  126. data/app/services/alchemy/page_tree_preloader.rb +105 -0
  127. data/app/stylesheets/alchemy/_extends.scss +3 -9
  128. data/app/stylesheets/alchemy/_mixins.scss +3 -1
  129. data/app/stylesheets/alchemy/_themes.scss +19 -10
  130. data/app/stylesheets/alchemy/admin/archive.scss +1 -0
  131. data/app/stylesheets/alchemy/admin/base.scss +5 -2
  132. data/app/stylesheets/alchemy/admin/buttons.scss +3 -3
  133. data/app/stylesheets/alchemy/admin/element-select.scss +18 -0
  134. data/app/stylesheets/alchemy/admin/elements.scss +123 -23
  135. data/app/stylesheets/alchemy/admin/errors.scss +1 -1
  136. data/app/stylesheets/alchemy/admin/flash.scss +6 -4
  137. data/app/stylesheets/alchemy/admin/images.scss +9 -5
  138. data/app/stylesheets/alchemy/admin/list_filter.scss +4 -4
  139. data/app/stylesheets/alchemy/admin/navigation.scss +1 -1
  140. data/app/stylesheets/alchemy/admin/notices.scss +1 -2
  141. data/app/stylesheets/alchemy/admin/selects.scss +36 -21
  142. data/app/stylesheets/alchemy/admin/shoelace.scss +14 -1
  143. data/app/stylesheets/alchemy/admin/sitemap.scss +11 -3
  144. data/app/stylesheets/alchemy/admin/tags.scss +3 -1
  145. data/app/stylesheets/alchemy/admin/toolbar.scss +1 -1
  146. data/app/views/alchemy/_edit_mode.html.erb +1 -1
  147. data/app/views/alchemy/_menubar.html.erb +1 -1
  148. data/app/views/alchemy/admin/attachments/_archive_overlay.html.erb +35 -31
  149. data/app/views/alchemy/admin/attachments/_library_sidebar.html.erb +6 -0
  150. data/app/views/alchemy/admin/attachments/_overlay_file_list.html.erb +1 -1
  151. data/app/views/alchemy/admin/attachments/_replace_button.html.erb +1 -8
  152. data/app/views/alchemy/admin/attachments/_sorting_select.html.erb +13 -0
  153. data/app/views/alchemy/admin/attachments/_tag_list.html.erb +2 -3
  154. data/app/views/alchemy/admin/attachments/index.html.erb +5 -11
  155. data/app/views/alchemy/admin/attachments/show.html.erb +1 -1
  156. data/app/views/alchemy/admin/clipboard/_button.html.erb +1 -0
  157. data/app/views/alchemy/admin/clipboard/index.html.erb +4 -5
  158. data/app/views/alchemy/admin/clipboard/insert.turbo_stream.erb +1 -1
  159. data/app/views/alchemy/admin/crop.html.erb +5 -7
  160. data/app/views/alchemy/admin/dashboard/widgets/_locked_pages.html.erb +1 -1
  161. data/app/views/alchemy/admin/elements/_add_nested_element_form.html.erb +6 -6
  162. data/app/views/alchemy/admin/elements/_fixed_element.html.erb +1 -1
  163. data/app/views/alchemy/admin/elements/_footer.html.erb +7 -1
  164. data/app/views/alchemy/admin/elements/_header.html.erb +5 -5
  165. data/app/views/alchemy/admin/elements/_toolbar.html.erb +33 -8
  166. data/app/views/alchemy/admin/elements/create.turbo_stream.erb +10 -10
  167. data/app/views/alchemy/admin/elements/index.html.erb +29 -16
  168. data/app/views/alchemy/admin/elements/new.html.erb +2 -2
  169. data/app/views/alchemy/admin/ingredients/update.turbo_stream.erb +3 -5
  170. data/app/views/alchemy/admin/leave.html.erb +1 -1
  171. data/app/views/alchemy/admin/nodes/_node.html.erb +19 -0
  172. data/app/views/alchemy/admin/nodes/edit.html.erb +1 -1
  173. data/app/views/alchemy/admin/nodes/index.html.erb +3 -1
  174. data/app/views/alchemy/admin/nodes/new.html.erb +14 -1
  175. data/app/views/alchemy/admin/pages/_current_page.html.erb +3 -1
  176. data/app/views/alchemy/admin/pages/_form.html.erb +21 -9
  177. data/app/views/alchemy/admin/pages/_page_status.html.erb +1 -1
  178. data/app/views/alchemy/admin/pages/_publication_fields.html.erb +28 -26
  179. data/app/views/alchemy/admin/pages/_table.html.erb +0 -7
  180. data/app/views/alchemy/admin/pages/_toolbar.html.erb +3 -6
  181. data/app/views/alchemy/admin/pages/edit.html.erb +5 -11
  182. data/app/views/alchemy/admin/pages/flush.turbo_stream.erb +2 -0
  183. data/app/views/alchemy/admin/pages/fold.turbo_stream.erb +5 -0
  184. data/app/views/alchemy/admin/pages/index.html.erb +5 -3
  185. data/app/views/alchemy/admin/pages/new.html.erb +2 -12
  186. data/app/views/alchemy/admin/pages/publish.turbo_stream.erb +12 -0
  187. data/app/views/alchemy/admin/pages/tree.html.erb +13 -0
  188. data/app/views/alchemy/admin/pages/update.turbo_stream.erb +5 -16
  189. data/app/views/alchemy/admin/partials/_flash_notices.html.erb +1 -1
  190. data/app/views/alchemy/admin/partials/{_remote_search_form.html.erb → _overlay_search_form.html.erb} +1 -2
  191. data/app/views/alchemy/admin/partials/_paste_from_clipboard_form.html.erb +12 -0
  192. data/app/views/alchemy/admin/pictures/_archive_overlay.html.erb +24 -21
  193. data/app/views/alchemy/admin/pictures/_filter_and_size_bar.html.erb +18 -26
  194. data/app/views/alchemy/admin/pictures/_picture.html.erb +11 -15
  195. data/app/views/alchemy/admin/pictures/_picture_to_assign.html.erb +3 -6
  196. data/app/views/alchemy/admin/pictures/_tag_list.html.erb +2 -3
  197. data/app/views/alchemy/admin/pictures/index.html.erb +0 -1
  198. data/app/views/alchemy/admin/pictures/update.turbo_stream.erb +1 -1
  199. data/app/views/alchemy/admin/resources/_resource_usage_info.html.erb +1 -1
  200. data/app/views/alchemy/admin/resources/_tag_list.html.erb +2 -3
  201. data/app/views/alchemy/admin/styleguide/index.html.erb +25 -20
  202. data/app/views/alchemy/admin/tags/edit.html.erb +1 -1
  203. data/app/views/alchemy/admin/tinymce/_setup.html.erb +2 -2
  204. data/app/views/alchemy/admin/uploader/_button.html.erb +1 -15
  205. data/app/views/alchemy/attachments/show.html.erb +1 -1
  206. data/app/views/alchemy/base/permission_denied.js.erb +1 -1
  207. data/app/views/alchemy/ingredients/shared/_anchor.html.erb +9 -7
  208. data/app/views/alchemy/ingredients/shared/_link_tools.html.erb +12 -5
  209. data/app/views/alchemy/ingredients/shared/_picture_tools.html.erb +10 -11
  210. data/app/views/alchemy/language_links/_spacer.html.erb +1 -1
  211. data/app/views/alchemy/messages_mailer/new.html.erb +1 -1
  212. data/app/views/alchemy/welcome.html.erb +1 -1
  213. data/config/locales/alchemy.en.yml +12 -3
  214. data/config/routes.rb +2 -2
  215. data/db/migrate/20230123112425_add_searchable_to_alchemy_pages.rb +1 -1
  216. data/db/migrate/20230505132743_add_indexes_to_alchemy_pictures.rb +1 -1
  217. data/db/migrate/20231113104432_create_page_mutexes.rb +1 -1
  218. data/db/migrate/20240314105244_create_alchemy_picture_descriptions.rb +1 -1
  219. data/db/migrate/20250626160259_add_unique_index_to_picture_descriptions.rb +1 -1
  220. data/db/migrate/20250905140323_add_created_at_index_to_pictures_and_attachments.rb +1 -1
  221. data/db/migrate/20251106150010_convert_select_value_for_multiple.rb +11 -0
  222. data/db/migrate/20260102121232_add_metadata_to_page_versions.rb +9 -0
  223. data/db/migrate/20260115164704_add_publication_timestamps_to_alchemy_elements.rb +30 -0
  224. data/db/migrate/20260115164705_add_index_to_element_publication_timestamps.rb +13 -0
  225. data/lib/alchemy/ability_helper.rb +1 -3
  226. data/lib/alchemy/auth_accessors.rb +51 -117
  227. data/lib/alchemy/configuration.rb +1 -0
  228. data/lib/alchemy/configurations/main.rb +63 -0
  229. data/lib/alchemy/controller_actions.rb +2 -3
  230. data/lib/alchemy/engine.rb +9 -12
  231. data/lib/alchemy/error_tracking/error_logger.rb +1 -1
  232. data/lib/alchemy/errors.rb +1 -1
  233. data/lib/alchemy/logger.rb +34 -4
  234. data/lib/alchemy/name_conversions.rb +0 -6
  235. data/lib/alchemy/seeder.rb +2 -2
  236. data/lib/alchemy/tasks/usage.rb +4 -4
  237. data/lib/alchemy/test_support/factories/page_version_factory.rb +3 -0
  238. data/lib/alchemy/test_support/having_picture_thumbnails_examples.rb +30 -0
  239. data/lib/alchemy/test_support/shared_ingredient_editor_examples.rb +26 -6
  240. data/lib/alchemy/test_support/shared_publishable_examples.rb +114 -0
  241. data/lib/alchemy/upgrader/eight_one.rb +56 -0
  242. data/lib/alchemy/upgrader.rb +9 -1
  243. data/lib/alchemy/version.rb +1 -1
  244. data/lib/alchemy.rb +1 -4
  245. data/lib/alchemy_cms.rb +0 -1
  246. data/lib/generators/alchemy/elements/templates/view.html.erb +3 -3
  247. data/lib/generators/alchemy/ingredient/ingredient_generator.rb +6 -8
  248. data/lib/generators/alchemy/ingredient/templates/editor_component.rb.tt +22 -0
  249. data/lib/generators/alchemy/page_layouts/templates/layout.html.erb +1 -1
  250. data/lib/generators/alchemy/site_layouts/templates/layout.html.erb +1 -1
  251. data/lib/tasks/alchemy/upgrade.rake +21 -7
  252. data/vendor/javascript/shoelace.min.js +713 -31
  253. data/vendor/javascript/tinymce.min.js +1 -1
  254. metadata +104 -84
  255. data/app/decorators/alchemy/element_editor.rb +0 -90
  256. data/app/helpers/alchemy/admin/pictures_helper.rb +0 -14
  257. data/app/javascript/alchemy_admin/file_editors.js +0 -28
  258. data/app/javascript/alchemy_admin/image_loader.js +0 -54
  259. data/app/javascript/alchemy_admin/page_sorter.js +0 -71
  260. data/app/javascript/alchemy_admin/sitemap.js +0 -154
  261. data/app/javascript/alchemy_admin/templates/page_folder.hbs +0 -3
  262. data/app/views/alchemy/admin/attachments/archive_overlay.js.erb +0 -4
  263. data/app/views/alchemy/admin/pages/_page.html.erb +0 -163
  264. data/app/views/alchemy/admin/pages/_sitemap.html.erb +0 -30
  265. data/app/views/alchemy/admin/pages/flush.js.erb +0 -2
  266. data/app/views/alchemy/admin/pictures/archive_overlay.js.erb +0 -5
  267. data/app/views/alchemy/admin/pictures/index.js.erb +0 -2
  268. data/app/views/alchemy/ingredients/_audio_editor.html.erb +0 -5
  269. data/app/views/alchemy/ingredients/_boolean_editor.html.erb +0 -11
  270. data/app/views/alchemy/ingredients/_datetime_editor.html.erb +0 -20
  271. data/app/views/alchemy/ingredients/_file_editor.html.erb +0 -52
  272. data/app/views/alchemy/ingredients/_headline_editor.html.erb +0 -44
  273. data/app/views/alchemy/ingredients/_html_editor.html.erb +0 -8
  274. data/app/views/alchemy/ingredients/_link_editor.html.erb +0 -30
  275. data/app/views/alchemy/ingredients/_node_editor.html.erb +0 -13
  276. data/app/views/alchemy/ingredients/_number_editor.html.erb +0 -24
  277. data/app/views/alchemy/ingredients/_page_editor.html.erb +0 -13
  278. data/app/views/alchemy/ingredients/_picture_editor.html.erb +0 -59
  279. data/app/views/alchemy/ingredients/_richtext_editor.html.erb +0 -15
  280. data/app/views/alchemy/ingredients/_select_editor.html.erb +0 -31
  281. data/app/views/alchemy/ingredients/_text_editor.html.erb +0 -29
  282. data/app/views/alchemy/ingredients/_video_editor.html.erb +0 -5
  283. data/lib/generators/alchemy/ingredient/templates/editor.html.erb +0 -14
  284. /data/{lib → app/models}/alchemy/permissions.rb +0 -0
@@ -10,7 +10,7 @@ module Alchemy
10
10
  @page = page
11
11
  end
12
12
 
13
- # Copies all currently visible elements to the public version of page
13
+ # Copies all currently publishable elements to the public version of page
14
14
  #
15
15
  # Creates a new published version if none exists yet and updates
16
16
  # the `published_at` timestamp of the page.
@@ -24,10 +24,12 @@ module Alchemy
24
24
  version = public_version(public_on)
25
25
  DeleteElements.new(version.elements).call
26
26
 
27
+ copy_metadata(public_version: version)
28
+
27
29
  repository = page.draft_version.element_repository
28
30
  ActiveRecord::Base.no_touching do
29
31
  Element.acts_as_list_no_update do
30
- repository.visible.not_nested.each.with_index(1) do |element, position|
32
+ repository.publishable.not_nested.each.with_index(1) do |element, position|
31
33
  Alchemy::DuplicateElement.new(element, repository: repository, publishable_only: true).call(
32
34
  page_version_id: version.id,
33
35
  position: position
@@ -51,6 +53,16 @@ module Alchemy
51
53
  def public_version(public_on)
52
54
  page.public_version || page.versions.create!(public_on: public_on)
53
55
  end
56
+
57
+ # Copy metadata from draft_version to public_version.
58
+ def copy_metadata(public_version:)
59
+ draft = page.draft_version
60
+ return unless draft
61
+
62
+ PageVersion::METADATA_ATTRIBUTES.each do |attr|
63
+ public_version.send(:"#{attr}=", draft.send(attr))
64
+ end
65
+ end
54
66
  end
55
67
  end
56
68
  end
@@ -7,12 +7,12 @@
7
7
  # id :integer not null, primary key
8
8
  # name :string
9
9
  # urlname :string
10
- # title :string
10
+ # title :string (deprecated - use draft_version.title)
11
11
  # language_code :string
12
12
  # language_root :boolean
13
13
  # page_layout :string
14
- # meta_keywords :text
15
- # meta_description :text
14
+ # meta_keywords :text (deprecated - use draft_version.meta_keywords)
15
+ # meta_description :text (deprecated - use draft_version.meta_description)
16
16
  # lft :integer
17
17
  # rgt :integer
18
18
  # parent_id :integer
@@ -44,9 +44,15 @@ require_dependency "alchemy/page/page_elements"
44
44
 
45
45
  module Alchemy
46
46
  class Page < BaseRecord
47
- include Alchemy::Logger
48
47
  include Alchemy::Taggable
49
48
 
49
+ # These columns are deprecated in favor of page versions
50
+ self.ignored_columns += [
51
+ "meta_description",
52
+ "meta_keywords",
53
+ "title"
54
+ ]
55
+
50
56
  DEFAULT_ATTRIBUTES_FOR_COPY = {
51
57
  autogenerate_elements: false,
52
58
  public_on: nil,
@@ -66,11 +72,12 @@ module Alchemy
66
72
  depth
67
73
  urlname
68
74
  cached_tag_list
75
+ title
76
+ meta_description
77
+ meta_keywords
69
78
  ]
70
79
 
71
80
  PERMITTED_ATTRIBUTES = [
72
- :meta_description,
73
- :meta_keywords,
74
81
  :name,
75
82
  :page_layout,
76
83
  :public_on,
@@ -81,33 +88,35 @@ module Alchemy
81
88
  :searchable,
82
89
  :sitemap,
83
90
  :tag_list,
84
- :title,
85
91
  :urlname,
86
92
  :layoutpage,
87
- :menu_id
93
+ :menu_id,
94
+ {
95
+ draft_version_attributes: [:id] + PageVersion::METADATA_ATTRIBUTES.map(&:to_sym)
96
+ }
88
97
  ]
89
98
 
90
99
  acts_as_nested_set(dependent: :destroy, scope: [:layoutpage, :language_id])
91
100
 
92
- stampable stamper_class_name: Alchemy.user_class_name
101
+ stampable stamper_class_name: Alchemy.config.user_class_name
93
102
 
94
103
  belongs_to :language
95
104
 
96
105
  belongs_to :creator,
97
- primary_key: Alchemy.user_class_primary_key,
98
- class_name: Alchemy.user_class_name,
106
+ primary_key: Alchemy.config.user_class_primary_key,
107
+ class_name: Alchemy.config.user_class_name,
99
108
  foreign_key: :creator_id,
100
109
  optional: true
101
110
 
102
111
  belongs_to :updater,
103
- primary_key: Alchemy.user_class_primary_key,
104
- class_name: Alchemy.user_class_name,
112
+ primary_key: Alchemy.config.user_class_primary_key,
113
+ class_name: Alchemy.config.user_class_name,
105
114
  foreign_key: :updater_id,
106
115
  optional: true
107
116
 
108
117
  belongs_to :locker,
109
- primary_key: Alchemy.user_class_primary_key,
110
- class_name: Alchemy.user_class_name,
118
+ primary_key: Alchemy.config.user_class_primary_key,
119
+ class_name: Alchemy.config.user_class_name,
111
120
  foreign_key: :locked_by,
112
121
  optional: true
113
122
 
@@ -117,8 +126,10 @@ module Alchemy
117
126
  has_many :legacy_urls, class_name: "Alchemy::LegacyPageUrl", dependent: :destroy
118
127
  has_many :nodes, class_name: "Alchemy::Node", inverse_of: :page, dependent: :restrict_with_error
119
128
  has_many :versions, class_name: "Alchemy::PageVersion", inverse_of: :page, dependent: :destroy
120
- has_one :draft_version, -> { drafts }, class_name: "Alchemy::PageVersion"
121
- has_one :public_version, -> { published }, class_name: "Alchemy::PageVersion", autosave: -> { persisted? }
129
+ has_one :draft_version, -> { draft.order(updated_at: :desc) }, class_name: "Alchemy::PageVersion"
130
+ has_one :public_version, -> { published.order(public_on: :desc) }, class_name: "Alchemy::PageVersion", autosave: -> { persisted? }
131
+
132
+ accepts_nested_attributes_for :draft_version
122
133
 
123
134
  has_many :page_ingredients, class_name: "Alchemy::Ingredients::Page", foreign_key: :related_object_id, dependent: :nullify
124
135
 
@@ -129,8 +140,7 @@ module Alchemy
129
140
  validates_format_of :page_layout, with: /\A[a-z0-9_-]+\z/, unless: -> { page_layout.blank? }
130
141
  validates_presence_of :parent, unless: -> { layoutpage? || language_root? }
131
142
 
132
- before_create -> { versions.build },
133
- if: -> { versions.none? }
143
+ after_initialize :ensure_draft_version, if: :new_record?
134
144
 
135
145
  before_save :set_language_code,
136
146
  if: -> { language.present? }
@@ -179,7 +189,7 @@ module Alchemy
179
189
  end
180
190
 
181
191
  def searchable_alchemy_resource_attributes
182
- %w[name urlname title]
192
+ %w[name urlname]
183
193
  end
184
194
 
185
195
  # @return the language root page for given language id.
@@ -213,8 +223,7 @@ module Alchemy
213
223
  .call(changed_attributes: {
214
224
  parent: new_parent,
215
225
  language: new_parent&.language,
216
- name: new_name,
217
- title: new_name
226
+ name: new_name
218
227
  })
219
228
  if source.children.any?
220
229
  source.copy_children_to(page)
@@ -402,6 +411,19 @@ module Alchemy
402
411
  PublishPageJob.perform_later(id, public_on: current_time)
403
412
  end
404
413
 
414
+ # Returns true if the draft version has changes not yet published.
415
+ #
416
+ # Compares the updated_at timestamp of the draft_version against the
417
+ # updated_at timestamp of the public_version. If there's no public_version,
418
+ # the page has never been published and always has unpublished changes.
419
+ #
420
+ # @return [Boolean]
421
+ def has_unpublished_changes?
422
+ return true unless public_version
423
+
424
+ draft_version.updated_at > public_version.updated_at
425
+ end
426
+
405
427
  # Sets the public_on date on the published version
406
428
  #
407
429
  # Builds a new version if none exists yet.
@@ -458,6 +480,54 @@ module Alchemy
458
480
  attribute_fixed?(:public_until) ? fixed_attributes[:public_until] : public_version&.public_until
459
481
  end
460
482
 
483
+ # Returns the title from the public version, falling back to draft version
484
+ #
485
+ # If it's a fixed attribute then the fixed value is returned instead
486
+ #
487
+ def title
488
+ return fixed_attributes[:title] if attribute_fixed?(:title)
489
+
490
+ public_version&.title || draft_version&.title
491
+ end
492
+
493
+ # Returns the meta_description from the public version, falling back to draft version
494
+ #
495
+ # If it's a fixed attribute then the fixed value is returned instead
496
+ #
497
+ def meta_description
498
+ return fixed_attributes[:meta_description] if attribute_fixed?(:meta_description)
499
+
500
+ public_version&.meta_description || draft_version&.meta_description
501
+ end
502
+
503
+ # Returns the meta_keywords from the public version, falling back to draft version
504
+ #
505
+ # If it's a fixed attribute then the fixed value is returned instead
506
+ #
507
+ def meta_keywords
508
+ return fixed_attributes[:meta_keywords] if attribute_fixed?(:meta_keywords)
509
+
510
+ public_version&.meta_keywords || draft_version&.meta_keywords
511
+ end
512
+
513
+ # @deprecated Use draft_version.title= instead
514
+ def title=(value)
515
+ draft_version&.title = value
516
+ end
517
+ deprecate "title=": :"page.draft_version.title=", deprecator: Alchemy::Deprecation
518
+
519
+ # @deprecated Use draft_version.meta_description= instead
520
+ def meta_description=(value)
521
+ draft_version&.meta_description = value
522
+ end
523
+ deprecate "meta_description=": :"page.draft_version.meta_description=", deprecator: Alchemy::Deprecation
524
+
525
+ # @deprecated Use draft_version.meta_keywords= instead
526
+ def meta_keywords=(value)
527
+ draft_version&.meta_keywords = value
528
+ end
529
+ deprecate "meta_keywords=": :"page.draft_version.meta_keywords=", deprecator: Alchemy::Deprecation
530
+
461
531
  # Returns the name of the creator of this page.
462
532
  #
463
533
  # If no creator could be found or associated user model
@@ -499,9 +569,18 @@ module Alchemy
499
569
 
500
570
  private
501
571
 
572
+ def ensure_draft_version
573
+ self.draft_version ||= versions.build
574
+ end
575
+
502
576
  def set_fixed_attributes
503
577
  fixed_attributes.all.each do |attribute, value|
504
- send(:"#{attribute}=", value)
578
+ attribute_name = attribute.to_s
579
+ if PageVersion::METADATA_ATTRIBUTES.include?(attribute_name)
580
+ draft_version&.send(:"#{attribute}=", value)
581
+ else
582
+ send(:"#{attribute}=", value)
583
+ end
505
584
  end
506
585
  end
507
586
 
@@ -4,7 +4,6 @@ module Alchemy
4
4
  class PageDefinition
5
5
  include ActiveModel::Model
6
6
  include ActiveModel::Attributes
7
- include Alchemy::Hints
8
7
 
9
8
  extend ActiveModel::Translation
10
9
 
@@ -22,6 +21,10 @@ module Alchemy
22
21
  attribute :editable_by
23
22
  attribute :hint
24
23
 
24
+ # Needs to be down here in order to have the attribute reader
25
+ # available after the attribute is defined.
26
+ include Alchemy::Hints
27
+
25
28
  validates :name,
26
29
  presence: true,
27
30
  format: {
@@ -2,19 +2,29 @@
2
2
 
3
3
  module Alchemy
4
4
  class PageVersion < BaseRecord
5
+ include Alchemy::Publishable
6
+
7
+ # Metadata attributes that are versioned (moved from Page)
8
+ METADATA_ATTRIBUTES = %w[
9
+ title
10
+ meta_description
11
+ meta_keywords
12
+ ].freeze
13
+
5
14
  belongs_to :page, class_name: "Alchemy::Page", inverse_of: :versions, touch: true
6
15
 
7
16
  has_many :elements, -> { order(:position) },
8
17
  class_name: "Alchemy::Element",
9
18
  inverse_of: :page_version
10
19
 
11
- scope :drafts, -> { where(public_on: nil).order(updated_at: :desc) }
12
- scope :published, -> { where.not(public_on: nil).order(public_on: :desc) }
20
+ before_create :set_title_from_page
21
+
22
+ class << self
23
+ alias_method :drafts, :draft
24
+ deprecate drafts: :draft, deprecator: Alchemy::Deprecation
13
25
 
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)
26
+ alias_method :public_on, :published
27
+ deprecate public_on: :published, deprecator: Alchemy::Deprecation
18
28
  end
19
29
 
20
30
  before_destroy :delete_elements
@@ -54,5 +64,11 @@ module Alchemy
54
64
  def delete_elements
55
65
  DeleteElements.new(elements).call
56
66
  end
67
+
68
+ def set_title_from_page
69
+ return if title.present?
70
+
71
+ self.title = page&.name
72
+ end
57
73
  end
58
74
  end
@@ -28,7 +28,6 @@ module Alchemy
28
28
  large: "240x180"
29
29
  }.with_indifferent_access.freeze
30
30
 
31
- include Alchemy::Logger
32
31
  include Alchemy::NameConversions
33
32
  include Alchemy::Taggable
34
33
  include Alchemy::TouchElements
@@ -63,6 +62,8 @@ module Alchemy
63
62
  @_preprocessor_class = klass
64
63
  end
65
64
 
65
+ before_create :set_name, if: :image_file_name
66
+
66
67
  include Alchemy.storage_adapter.picture_class_methods
67
68
 
68
69
  # We need to define this method here to have it available in the validations below.
@@ -76,7 +77,7 @@ module Alchemy
76
77
  validates_size_of :image_file, maximum: Alchemy.config.uploader.file_size_limit.megabytes
77
78
  validate :image_file_type_allowed, if: -> { image_file.present? }
78
79
 
79
- stampable stamper_class_name: Alchemy.user_class_name
80
+ stampable stamper_class_name: Alchemy.config.user_class_name
80
81
 
81
82
  scope :named, ->(name) { where("#{table_name}.name LIKE ?", "%#{name}%") }
82
83
  scope :recent, -> { where("#{table_name}.created_at > ?", Time.current - 24.hours).order(:created_at) }
@@ -148,7 +149,7 @@ module Alchemy
148
149
 
149
150
  self.class.url_class.new(self).call(options)
150
151
  rescue Alchemy.storage_adapter.rescuable_errors => e
151
- log_warning(e.message)
152
+ Logger.warn(e.message)
152
153
  nil
153
154
  end
154
155
 
@@ -196,14 +197,6 @@ module Alchemy
196
197
  end
197
198
  end
198
199
 
199
- # Returns a humanized, readable name from image filename.
200
- #
201
- def humanized_name
202
- return "" if image_file_name.blank?
203
-
204
- convert_to_humanized_name(image_file_name, image_file_extension)
205
- end
206
-
207
200
  # Returns the format the image should be rendered with
208
201
  #
209
202
  # Only returns a format differing from original if an +image_output_format+
@@ -288,6 +281,12 @@ module Alchemy
288
281
 
289
282
  private
290
283
 
284
+ # Returns a humanized, readable name from image filename.
285
+ #
286
+ def set_name
287
+ self.name ||= Alchemy.storage_adapter.image_file_basename(self).humanize
288
+ end
289
+
291
290
  def image_file_type_allowed
292
291
  unless image_file_extension&.in?(self.class.allowed_filetypes)
293
292
  errors.add(:image_file, Alchemy.t("not a valid image"))
@@ -10,8 +10,6 @@ module Alchemy
10
10
  class PictureVariant
11
11
  extend Forwardable
12
12
 
13
- include Alchemy::Logger
14
-
15
13
  ANIMATED_IMAGE_FORMATS = %w[gif webp]
16
14
  TRANSPARENT_IMAGE_FORMATS = %w[gif webp png]
17
15
  ENCODABLE_IMAGE_FORMATS = %w[jpg jpeg webp]
@@ -57,7 +55,7 @@ module Alchemy
57
55
  image = processed_image(image, @options)
58
56
  encoded_image(image, @options)
59
57
  rescue MissingImageFileError, WrongImageFormatError => e
60
- log_warning(e.message)
58
+ Logger.warn(e.message)
61
59
  nil
62
60
  end
63
61
 
@@ -221,7 +221,7 @@ module Alchemy
221
221
  attributes.select { |a| searchable_attribute?(a) }
222
222
  .concat(searchable_relation_attributes(attributes))
223
223
  .collect { |h| h[:name] }
224
- end
224
+ end.sort
225
225
  end
226
226
 
227
227
  # Search field input name
@@ -4,8 +4,6 @@ module Alchemy
4
4
  module PictureClassMethods
5
5
  def self.included(base)
6
6
  base.has_one_attached :image_file do |attachable|
7
- # Only works in Rails 7.1+
8
- # https://github.com/rails/rails/pull/47473
9
7
  Alchemy.storage_adapter.preprocessor_class.new(attachable).call
10
8
  Alchemy.storage_adapter.preprocessor_class.generate_thumbs!(attachable)
11
9
  end
@@ -98,6 +96,13 @@ module Alchemy
98
96
  Attachment.with_attached_file.joins(:file_blob).where.not(active_storage_blobs: {content_type: file_type})
99
97
  end
100
98
 
99
+ # @param [Alchemy::Attachment]
100
+ # @return [String]
101
+ def file_basename(attachment)
102
+ filename = attachment.file&.filename.to_s
103
+ File.basename(filename, File.extname(filename))
104
+ end
105
+
101
106
  # @param [Alchemy::Attachment]
102
107
  # @return [String]
103
108
  def file_name(attachment)
@@ -128,6 +133,13 @@ module Alchemy
128
133
  picture.image_file&.variable?
129
134
  end
130
135
 
136
+ # @param [Alchemy::Picture]
137
+ # @return [String]
138
+ def image_file_basename(picture)
139
+ filename = picture.image_file&.filename.to_s
140
+ File.basename(filename, File.extname(filename))
141
+ end
142
+
131
143
  # @param [Alchemy::Picture]
132
144
  # @return [String]
133
145
  def image_file_name(picture)
@@ -127,6 +127,12 @@ module Alchemy
127
127
  attachment.read_attribute(:file_name)
128
128
  end
129
129
 
130
+ # @param [Alchemy::Attachment]
131
+ # @return [String]
132
+ def file_basename(attachment)
133
+ attachment.file&.basename&.to_s
134
+ end
135
+
130
136
  # @param [Alchemy::Attachment]
131
137
  # @return [Integer]
132
138
  def file_size(attachment)
@@ -152,6 +158,12 @@ module Alchemy
152
158
  image_file_extension(picture).in?(CONVERTIBLE_FILE_FORMATS)
153
159
  end
154
160
 
161
+ # @param [Alchemy::Picture]
162
+ # @return [String]
163
+ def image_file_basename(picture)
164
+ picture.image_file&.basename&.to_s
165
+ end
166
+
155
167
  # @param [Alchemy::Picture]
156
168
  # @return [String]
157
169
  def image_file_name(picture)
@@ -9,12 +9,14 @@ module Alchemy
9
9
  :by_file_format_scope,
10
10
  :by_file_type_scope,
11
11
  :not_file_type_scope,
12
+ :file_basename,
12
13
  :file_extension,
13
14
  :file_formats,
14
15
  :file_mime_type,
15
16
  :file_name,
16
17
  :file_size,
17
18
  :has_convertible_format?,
19
+ :image_file_basename,
18
20
  :image_file_extension,
19
21
  :image_file_format,
20
22
  :image_file_height,
@@ -66,20 +66,20 @@ module Alchemy
66
66
  # image displayed in the frontend.
67
67
  #
68
68
  # @return [String]
69
- def thumbnail_url
69
+ def thumbnail_url(size: "160x120")
70
70
  return if picture.nil?
71
71
 
72
- picture.url(thumbnail_url_options) || "alchemy/missing-image.svg"
72
+ picture.url(thumbnail_url_options(size: size)) || "alchemy/missing-image.svg"
73
73
  end
74
74
 
75
75
  # Thumbnail rendering options
76
76
  #
77
77
  # @return [HashWithIndifferentAccess]
78
- def thumbnail_url_options
78
+ def thumbnail_url_options(size: "160x120")
79
79
  crop = !!settings[:crop]
80
80
 
81
81
  {
82
- size: "160x120",
82
+ size: size,
83
83
  crop: crop,
84
84
  crop_from: crop && crop_from.presence || default_crop_from&.join("x"),
85
85
  crop_size: crop && crop_size.presence || default_crop_size&.join("x"),
@@ -0,0 +1,54 @@
1
+ module Alchemy
2
+ module Publishable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ scope :draft, -> { where(public_on: nil) }
7
+ scope :scheduled, -> { where.not(public_on: nil) }
8
+
9
+ scope :published, ->(at: Time.current) {
10
+ scheduled
11
+ .where("#{table_name}.public_on <= :at", at:)
12
+ .where(public_until: nil).or(
13
+ where("#{table_name}.public_until > :at", at:)
14
+ )
15
+ }
16
+ end
17
+
18
+ # Determines if this record is public
19
+ #
20
+ # Takes the two timestamps +public_on+ and +public_until+
21
+ # and returns true if the time given (+Time.current+ per default)
22
+ # is in this timespan.
23
+ #
24
+ # @param time [DateTime] (Time.current)
25
+ # @returns Boolean
26
+ def public?(time = Time.current)
27
+ already_public_for?(time) && still_public_for?(time)
28
+ end
29
+ alias_method :public, :public?
30
+
31
+ # Determines if this record is publishable
32
+ #
33
+ # A record is publishable if a +public_on+ timestamp is set and not expired yet.
34
+ #
35
+ # @returns Boolean
36
+ def publishable?
37
+ !public_on.nil? && still_public_for?
38
+ end
39
+
40
+ # Determines if this record is already public for given time
41
+ # @param time [DateTime] (Time.current)
42
+ # @returns Boolean
43
+ def already_public_for?(time = Time.current)
44
+ !public_on.nil? && public_on <= time
45
+ end
46
+
47
+ # Determines if this record is still public for given time
48
+ # @param time [DateTime] (Time.current)
49
+ # @returns Boolean
50
+ def still_public_for?(time = Time.current)
51
+ public_until.nil? || public_until >= time
52
+ end
53
+ end
54
+ end
@@ -7,43 +7,23 @@ module Alchemy
7
7
  end
8
8
 
9
9
  def pages
10
- tree = []
11
- path = [{id: object.parent_id, children: tree}]
12
- page_list = object.self_and_descendants.includes(:public_version, {language: :site})
13
10
  base_level = object.level - 1
14
- # Load folded pages in advance
15
- folded_user_pages = FoldedPage.folded_for_user(opts[:user]).pluck(:page_id)
16
- folded_depth = Float::INFINITY
17
-
18
- page_list.each_with_index do |page, i|
19
- has_children = page_list[i + 1] && page_list[i + 1].parent_id == page.id
20
- folded = has_children && folded_user_pages.include?(page.id)
11
+ build_pages_tree([object], base_level)
12
+ end
21
13
 
22
- if page.depth > folded_depth
23
- next
24
- else
25
- folded_depth = Float::INFINITY
26
- end
14
+ private
27
15
 
28
- # If this page is folded, skip all pages that are on a higher level (further down the tree).
29
- if folded && !opts[:full]
30
- folded_depth = page.depth
31
- end
16
+ def build_pages_tree(pages, level)
17
+ pages.map do |page|
18
+ # Use association target directly to avoid triggering queries
19
+ children = page.association(:children).loaded? ? page.association(:children).target : []
20
+ has_children = children.any?
21
+ folded = !has_children && !page.leaf?
32
22
 
33
- if page.parent_id != path.last[:id]
34
- if path.map { |o| o[:id] }.include?(page.parent_id) # Lower level
35
- path.pop while path.last[:id] != page.parent_id
36
- else # One level up
37
- path << path.last[:children].last
38
- end
23
+ page_hash(page, level, folded).tap do |hash|
24
+ hash[:children] = build_pages_tree(children, level + 1)
39
25
  end
40
-
41
- level = path.count + base_level
42
-
43
- path.last[:children] << page_hash(page, level, folded)
44
26
  end
45
-
46
- tree
47
27
  end
48
28
 
49
29
  protected
@@ -28,8 +28,14 @@ module Alchemy
28
28
  depth
29
29
  urlname
30
30
  cached_tag_list
31
+ title
32
+ meta_description
33
+ meta_keywords
31
34
  ]
32
35
 
36
+ # Metadata to copy via nested attributes (title is derived from page.name)
37
+ METADATA_ATTRIBUTES_TO_COPY = (Alchemy::PageVersion::METADATA_ATTRIBUTES - %w[title]).freeze
38
+
33
39
  attr_reader :page
34
40
 
35
41
  # @param page [Alchemy::Page]
@@ -70,6 +76,7 @@ module Alchemy
70
76
  .merge(DEFAULT_ATTRIBUTES_FOR_COPY)
71
77
  .merge(differences)
72
78
  desired_attributes["name"] = best_name_for_copy(source_attributes, desired_attributes)
79
+ desired_attributes["draft_version_attributes"] = draft_version_attributes_for_copy
73
80
  desired_attributes.except(*SKIPPED_ATTRIBUTES_ON_COPY)
74
81
  end
75
82
 
@@ -94,5 +101,15 @@ module Alchemy
94
101
  desired_name
95
102
  end
96
103
  end
104
+
105
+ # Builds nested attributes for draft_version metadata (except title).
106
+ # Title is handled by PageVersion#set_title_from_page callback based on page.name.
107
+ def draft_version_attributes_for_copy
108
+ return {} unless page.draft_version
109
+
110
+ METADATA_ATTRIBUTES_TO_COPY.each_with_object({}) do |attr, hash|
111
+ hash[attr] = page.draft_version.send(attr)
112
+ end
113
+ end
97
114
  end
98
115
  end
@@ -36,7 +36,7 @@ module Alchemy
36
36
  new_element.save!
37
37
 
38
38
  nested_elements = repository.children_of(source_element)
39
- nested_elements = nested_elements.visible if publishable_only
39
+ nested_elements = nested_elements.publishable if publishable_only
40
40
  Element.acts_as_list_no_update do
41
41
  nested_elements.each.with_index(1) do |nested_element, position|
42
42
  self.class.new(nested_element, repository: repository, publishable_only: publishable_only).call(