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
@@ -1 +1 @@
1
- (()=>{var n=Handlebars.template,e=Handlebars.templates=Handlebars.templates||{};e["node_folder.hbs"]=n({1:function(n,e,l,a,r){return"right"},3:function(n,e,l,a,r){return"down"},compiler:[8,">= 4.3.0"],main:function(n,e,l,a,r){var o,t=n.lambda,u=n.escapeExpression,c=n.lookupProperty||function(n,e){if(Object.prototype.hasOwnProperty.call(n,e))return n[e]};return'<a class="node_folder" data-record-id="'+u(t(null!=(o=null!=e?c(e,"node"):e)?c(o,"id"):o,e))+'" data-record-type="'+u(t(null!=(o=null!=e?c(e,"node"):e)?c(o,"type"):o,e))+'">\n <alchemy-icon name="arrow-'+(null!=(o=c(l,"if").call(null!=e?e:n.nullContext||{},null!=(o=null!=e?c(e,"node"):e)?c(o,"folded"):o,{name:"if",hash:{},fn:n.program(1,r,0),inverse:n.program(3,r,0),data:r,loc:{start:{line:2,column:28},end:{line:2,column:72}}}))?o:"")+'-s"></alchemy-icon>\n</a>\n'},useData:!0}),e["page_folder.hbs"]=n({1:function(n,e,l,a,r){return"right"},3:function(n,e,l,a,r){return"down"},compiler:[8,">= 4.3.0"],main:function(n,e,l,a,r){var o,t=n.lookupProperty||function(n,e){if(Object.prototype.hasOwnProperty.call(n,e))return n[e]};return'<a class="page_folder icon_button" data-page-id="'+n.escapeExpression(n.lambda(null!=(o=null!=e?t(e,"page"):e)?t(o,"id"):o,e))+'">\n <alchemy-icon name="arrow-'+(null!=(o=t(l,"if").call(null!=e?e:n.nullContext||{},null!=(o=null!=e?t(e,"page"):e)?t(o,"folded"):o,{name:"if",hash:{},fn:n.program(1,r,0),inverse:n.program(3,r,0),data:r,loc:{start:{line:2,column:28},end:{line:2,column:72}}}))?o:"")+'-s"></alchemy-icon>\n</a>\n'},useData:!0})})();
1
+ (()=>{var n=Handlebars.template;(Handlebars.templates=Handlebars.templates||{})["node_folder.hbs"]=n({1:function(n,e,l,a,r){return"right"},3:function(n,e,l,a,r){return"down"},compiler:[8,">= 4.3.0"],main:function(n,e,l,a,r){var o,t=n.lambda,d=n.escapeExpression,u=n.lookupProperty||function(n,e){if(Object.prototype.hasOwnProperty.call(n,e))return n[e]};return'<a class="node_folder" data-record-id="'+d(t(null!=(o=null!=e?u(e,"node"):e)?u(o,"id"):o,e))+'" data-record-type="'+d(t(null!=(o=null!=e?u(e,"node"):e)?u(o,"type"):o,e))+'">\n <alchemy-icon name="arrow-'+(null!=(o=u(l,"if").call(null!=e?e:n.nullContext||{},null!=(o=null!=e?u(e,"node"):e)?u(o,"folded"):o,{name:"if",hash:{},fn:n.program(1,r,0),inverse:n.program(3,r,0),data:r,loc:{start:{line:2,column:28},end:{line:2,column:72}}}))?o:"")+'-s"></alchemy-icon>\n</a>\n'},useData:!0})})();
@@ -1,4 +1,5 @@
1
1
  const JSON_CONTENT_TYPE = "application/json"
2
+ const TURBO_STREAM_CONTENT_TYPE = "text/vnd.turbo-stream.html"
2
3
 
3
4
  function isGetRequest(method) {
4
5
  return method.toLowerCase() === "get"
@@ -43,8 +44,8 @@ export function get(url, params) {
43
44
  return ajax("GET", url, params)
44
45
  }
45
46
 
46
- export function patch(url, data) {
47
- return ajax("PATCH", url, data)
47
+ export function patch(url, data, accept) {
48
+ return ajax("PATCH", url, data, accept)
48
49
  }
49
50
 
50
51
  export function post(url, data, accept = JSON_CONTENT_TYPE) {
@@ -63,7 +64,18 @@ export default async function ajax(
63
64
  )
64
65
  const contentType = response.headers.get("content-type")
65
66
  const isJson = contentType?.includes(JSON_CONTENT_TYPE)
66
- const responseData = isJson ? await response.json() : null
67
+ const isTurboStream = contentType?.includes(TURBO_STREAM_CONTENT_TYPE)
68
+
69
+ let responseData = null
70
+ if (isJson) {
71
+ responseData = await response.json()
72
+ } else if (isTurboStream) {
73
+ responseData = await response.text()
74
+ // Automatically render Turbo Stream if Turbo is available
75
+ if (typeof Turbo !== "undefined") {
76
+ Turbo.renderStreamMessage(responseData)
77
+ }
78
+ }
67
79
 
68
80
  if (response.ok) {
69
81
  return { data: responseData, status: response.status }
@@ -12,13 +12,10 @@ import { currentDialog, closeCurrentDialog } from "alchemy_admin/dialog"
12
12
  import Dirty from "alchemy_admin/dirty"
13
13
  import * as FixedElements from "alchemy_admin/fixed_elements"
14
14
  import { growl } from "alchemy_admin/growler"
15
- import ImageLoader from "alchemy_admin/image_loader"
16
15
  import Initializer from "alchemy_admin/initializer"
17
16
  import { LinkDialog } from "alchemy_admin/link_dialog"
18
17
  import pleaseWaitOverlay from "alchemy_admin/please_wait_overlay"
19
- import Sitemap from "alchemy_admin/sitemap"
20
18
  import Spinner from "alchemy_admin/spinner"
21
- import PagePublicationFields from "alchemy_admin/page_publication_fields"
22
19
  import { reloadPreview } from "alchemy_admin/components/preview_window"
23
20
  import { openConfirmDialog } from "alchemy_admin/confirm_dialog"
24
21
 
@@ -44,12 +41,9 @@ Object.assign(Alchemy, {
44
41
  t: translate, // Global utility method for translating a given string
45
42
  FixedElements,
46
43
  growl,
47
- ImageLoader: ImageLoader.init,
48
44
  LinkDialog,
49
45
  pleaseWaitOverlay,
50
- Sitemap,
51
46
  Spinner,
52
- PagePublicationFields,
53
47
  reloadPreview
54
48
  })
55
49
 
@@ -25,9 +25,11 @@ module Alchemy
25
25
  include Alchemy::TouchElements
26
26
  include Alchemy::RelatableResource
27
27
 
28
+ before_save :set_name, if: -> { Alchemy.storage_adapter.set_attachment_name?(self) }
29
+
28
30
  include Alchemy.storage_adapter.attachment_class_methods
29
31
 
30
- stampable stamper_class_name: Alchemy.user_class_name
32
+ stampable stamper_class_name: Alchemy.config.user_class_name
31
33
 
32
34
  scope :by_file_type, ->(*file_type) do
33
35
  Alchemy.storage_adapter.by_file_type_scope(file_type)
@@ -97,8 +99,6 @@ module Alchemy
97
99
  validate :file_type_allowed,
98
100
  unless: -> { self.class.allowed_filetypes.include?("*") }
99
101
 
100
- before_save :set_name, if: -> { Alchemy.storage_adapter.set_attachment_name?(self) }
101
-
102
102
  scope :with_file_type, ->(file_type) { where(file_mime_type: file_type) }
103
103
 
104
104
  # Instance methods
@@ -179,7 +179,7 @@ module Alchemy
179
179
  end
180
180
 
181
181
  def set_name
182
- self.name ||= convert_to_humanized_name(file_name, extension)
182
+ self.name ||= Alchemy.storage_adapter.file_basename(self).humanize
183
183
  end
184
184
  end
185
185
  end
@@ -44,8 +44,7 @@ module Alchemy
44
44
  if (definition = self.class.definition_by_name(name))
45
45
  definition
46
46
  else
47
- log_warning "Could not find element definition for #{name}. " \
48
- "Please check your elements.yml file!"
47
+ Logger.warn "Could not find element definition for '#{name}'! Please check your element definitions."
49
48
  ElementDefinition.new
50
49
  end
51
50
  end
@@ -61,8 +61,12 @@ module Alchemy
61
61
  if ingredient_definitions.blank?
62
62
  nil
63
63
  else
64
- ingredient_definitions.find { _1.role == role.to_s } ||
65
- log_warning("Element #{name} is missing the ingredient definition for #{role}")
64
+ definition = ingredient_definitions.find { _1.role == role.to_s }
65
+ return definition if definition.present?
66
+
67
+ Logger.warn <<-WARN.strip_heredoc
68
+ Element '#{name}' is missing the ingredient definition for '#{role}'! Please check your element definitions.
69
+ WARN
66
70
  end
67
71
  end
68
72
 
@@ -8,7 +8,8 @@
8
8
  # name :string
9
9
  # position :integer
10
10
  # page_version_id :integer not null
11
- # public :boolean default(TRUE)
11
+ # public_on :timestamp
12
+ # public_until :timestamp
12
13
  # fixed :boolean default(FALSE)
13
14
  # folded :boolean default(FALSE)
14
15
  # unique :boolean default(FALSE)
@@ -28,8 +29,13 @@ module Alchemy
28
29
  class Element < BaseRecord
29
30
  NAME_REGEXP = /\A[a-z0-9_-]+\z/
30
31
 
31
- include Alchemy::Logger
32
+ # These columns are deprecated in favor of publication time stamps
33
+ self.ignored_columns += [
34
+ "public"
35
+ ]
36
+
32
37
  include Alchemy::Taggable
38
+ include Publishable
33
39
 
34
40
  FORBIDDEN_DEFINITION_ATTRIBUTES = [
35
41
  "amount",
@@ -56,7 +62,7 @@ module Alchemy
56
62
  #
57
63
  acts_as_list scope: [:page_version_id, :fixed, :parent_element_id]
58
64
 
59
- stampable stamper_class_name: Alchemy.user_class_name
65
+ stampable stamper_class_name: Alchemy.config.user_class_name
60
66
 
61
67
  before_destroy :delete_all_nested_elements
62
68
 
@@ -91,13 +97,13 @@ module Alchemy
91
97
  validates_presence_of :name, on: :create
92
98
  validates_format_of :name, on: :create, with: NAME_REGEXP
93
99
 
100
+ after_initialize :set_default_public_on, if: :new_record?
101
+
94
102
  attr_accessor :autogenerate_nested_elements
95
103
  after_create :generate_nested_elements, unless: -> { autogenerate_nested_elements == false }
96
104
 
97
105
  after_update :touch_touchable_pages
98
106
 
99
- scope :published, -> { where(public: true) }
100
- scope :hidden, -> { where(public: false) }
101
107
  scope :not_restricted, -> { joins(:page).merge(Page.not_restricted) }
102
108
  scope :named, ->(names) { where(name: names) }
103
109
  scope :excluded, ->(names) { where.not(name: names) }
@@ -118,6 +124,9 @@ module Alchemy
118
124
 
119
125
  # class methods
120
126
  class << self
127
+ alias_method :hidden, :draft
128
+ deprecate hidden: :draft, deprecator: Alchemy::Deprecation
129
+
121
130
  # Builds a new element as described in +/config/alchemy/elements.yml+
122
131
  #
123
132
  # - Returns a new Alchemy::Element object if no name is given in attributes,
@@ -174,13 +183,21 @@ module Alchemy
174
183
  end
175
184
  end
176
185
 
177
- # Heavily unoptimized naive way to get all parent ids
178
- def parent_element_ids
179
- ids ||= []
180
- parent = parent_element
181
- while parent
182
- ids.push parent.id
183
- parent = parent.parent_element
186
+ # Returns IDs of all folded parent elements from immediate parent up to root
187
+ #
188
+ # Walks up the ancestor chain and collects only the ones that are folded,
189
+ # skipping already expanded parents.
190
+ #
191
+ # @return [Array<Integer>] Folded parent element IDs from immediate parent to root
192
+ def folded_parent_element_ids
193
+ return [] unless parent_element_id
194
+
195
+ ids = []
196
+ current_id = parent_element_id
197
+ while current_id
198
+ folded, parent_id = self.class.where(id: current_id).pick(:folded, :parent_element_id)
199
+ ids << current_id if folded
200
+ current_id = parent_id
184
201
  end
185
202
  ids
186
203
  end
@@ -212,6 +229,25 @@ module Alchemy
212
229
  end
213
230
  end
214
231
 
232
+ # Convenience setter to set public_on attribute
233
+ # when setting public to true or false.
234
+ def public=(value)
235
+ @public_on_explicitely_set = true
236
+ if ActiveModel::Type::Boolean.new.cast(value)
237
+ self.public_on ||= Time.current
238
+ else
239
+ self.public_on = nil
240
+ end
241
+ end
242
+
243
+ # Override setter to track if public_on was already set
244
+ # in order to not override it with default value if someone
245
+ # explicitly set it to nil.
246
+ def public_on=(value)
247
+ @public_on_explicitely_set = true
248
+ super
249
+ end
250
+
215
251
  # Returns true if the definition of this element has a taggable true value.
216
252
  def taggable?
217
253
  definition.taggable == true
@@ -284,12 +320,17 @@ module Alchemy
284
320
 
285
321
  private
286
322
 
323
+ def set_default_public_on
324
+ return if @public_on_explicitely_set
325
+ self.public_on ||= Time.current
326
+ end
327
+
287
328
  def generate_nested_elements
288
329
  definition.autogenerate.each do |nestable_element|
289
330
  if nestable_elements.include?(nestable_element)
290
331
  Element.create(page_version: page_version, parent_element_id: id, name: nestable_element)
291
332
  else
292
- log_warning("Element '#{nestable_element}' not a nestable element for '#{name}'. Skipping!")
333
+ Logger.warn("Element '#{nestable_element}' not a nestable element for '#{name}'. Skipping!")
293
334
  end
294
335
  end
295
336
  end
@@ -4,7 +4,6 @@ module Alchemy
4
4
  class ElementDefinition
5
5
  include ActiveModel::Model
6
6
  include ActiveModel::Attributes
7
- include Alchemy::Hints
8
7
 
9
8
  extend ActiveModel::Translation
10
9
 
@@ -24,6 +23,10 @@ module Alchemy
24
23
  attribute :icon
25
24
  attribute :searchable, :boolean, default: true
26
25
 
26
+ # Needs to be down here in order to have the attribute reader
27
+ # available after the attribute is defined.
28
+ include Alchemy::Hints
29
+
27
30
  validates :name,
28
31
  presence: true,
29
32
  format: {
@@ -22,6 +22,12 @@ module Alchemy
22
22
  self.class.new select(&:public)
23
23
  end
24
24
 
25
+ # All publishable elements
26
+ # @return [Alchemy::ElementRepository]
27
+ def publishable
28
+ self.class.new select(&:publishable?)
29
+ end
30
+
25
31
  # All not fixed elements
26
32
  # @return [Alchemy::ElementRepository]
27
33
  def hidden
@@ -13,10 +13,10 @@
13
13
  module Alchemy
14
14
  class FoldedPage < BaseRecord
15
15
  belongs_to :page, inverse_of: :folded_pages
16
- belongs_to :user, inverse_of: :folded_pages, class_name: Alchemy.user_class_name
16
+ belongs_to :user, inverse_of: :folded_pages, class_name: Alchemy.config.user_class_name
17
17
 
18
18
  def self.folded_for_user(user)
19
- return none unless Alchemy.user_class < ActiveRecord::Base
19
+ return none unless Alchemy.config.user_class.respond_to?(:where)
20
20
 
21
21
  where(user: user, folded: true)
22
22
  end
@@ -110,6 +110,25 @@ module Alchemy
110
110
  element.ingredient_definition_for(role) || IngredientDefinition.new
111
111
  end
112
112
 
113
+ # Returns the translated role for displaying in labels
114
+ #
115
+ # Translate it in your locale yml file:
116
+ #
117
+ # alchemy:
118
+ # ingredient_roles:
119
+ # foo: Bar
120
+ #
121
+ # Optionally you can scope your ingredient role to an element:
122
+ #
123
+ # alchemy:
124
+ # ingredient_roles:
125
+ # article:
126
+ # foo: Baz
127
+ #
128
+ def translated_role
129
+ self.class.translated_label_for(role, element&.name)
130
+ end
131
+
113
132
  # The first 30 characters of the value
114
133
  #
115
134
  # Used by the Element#preview_text method.
@@ -141,6 +160,10 @@ module Alchemy
141
160
  false
142
161
  end
143
162
 
163
+ def linked?
164
+ link.try(:present?)
165
+ end
166
+
144
167
  # @return [Boolean]
145
168
  def preview_ingredient?
146
169
  !!definition.as_element_title
@@ -154,10 +177,24 @@ module Alchemy
154
177
  view_component_class.new(self, **options, html_options: html_options)
155
178
  end
156
179
 
180
+ # The editor component of the ingredient.
181
+ #
182
+ def as_editor_component
183
+ editor_component_class.new(self)
184
+ end
185
+
157
186
  private
158
187
 
159
188
  def view_component_class
160
- @_view_component_class ||= "#{self.class.name}View".constantize
189
+ @_view_component_class ||= component_class_name(part: "View").constantize
190
+ end
191
+
192
+ def editor_component_class
193
+ @_editor_component_class ||= component_class_name(part: "Editor").constantize
194
+ end
195
+
196
+ def component_class_name(part:)
197
+ "#{self.class.name}#{part}"
161
198
  end
162
199
 
163
200
  def set_default_value
@@ -4,7 +4,6 @@ module Alchemy
4
4
  class IngredientDefinition
5
5
  include ActiveModel::Model
6
6
  include ActiveModel::Attributes
7
- include Alchemy::Hints
8
7
 
9
8
  extend ActiveModel::Translation
10
9
 
@@ -19,6 +18,10 @@ module Alchemy
19
18
  attribute :hint
20
19
  attribute :searchable, :boolean, default: true
21
20
 
21
+ # Needs to be down here in order to have the attribute reader
22
+ # available after the attribute is defined.
23
+ include Alchemy::Hints
24
+
22
25
  validates :role,
23
26
  presence: true,
24
27
  format: {
@@ -80,6 +80,8 @@ module Alchemy
80
80
  end
81
81
 
82
82
  def validate_format(format)
83
+ return if ingredient.value.blank?
84
+
83
85
  matcher = if format.is_a?(String) || format.is_a?(Symbol)
84
86
  Alchemy.config.format_matchers.get(format)
85
87
  else
@@ -91,6 +93,8 @@ module Alchemy
91
93
  end
92
94
 
93
95
  def validate_length(opts = {})
96
+ return if ingredient.value.blank?
97
+
94
98
  value_length = ingredient.value.to_s.length
95
99
  if value_length < opts[:minimum]
96
100
  ingredient.errors.add(:value, :too_short, count: opts[:minimum])
@@ -103,10 +107,10 @@ module Alchemy
103
107
  def duplicates
104
108
  ingredient.class
105
109
  .joins(:element, :page_version, :language)
106
- .merge(Alchemy::PageVersion.drafts)
110
+ .merge(Alchemy::PageVersion.draft)
107
111
  .merge(Alchemy::Language.where(id: ingredient.language.id))
108
112
  .where(Alchemy::Element.table_name => {name: ingredient.element.name})
109
- .where(value: ingredient.value)
113
+ .where(ingredient.class.arel_table[:value].eq(ingredient.value_before_type_cast))
110
114
  .where.not(id: ingredient.id)
111
115
  end
112
116
  end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ module Ingredients
5
+ # A Color value
6
+ #
7
+ class Color < Alchemy::Ingredient
8
+ end
9
+ end
10
+ end
@@ -24,23 +24,6 @@ module Alchemy
24
24
  "H#{level}: #{value}"[0..maxlength - 1]
25
25
  end
26
26
 
27
- def level_options
28
- levels.map { |level| ["H#{level}", level] }
29
- end
30
-
31
- def size_options
32
- sizes.map do |size|
33
- case size
34
- when Array
35
- size
36
- else
37
- [".h#{size}", size]
38
- end
39
- end
40
- end
41
-
42
- private
43
-
44
27
  def levels
45
28
  settings.fetch(:levels, 1..6)
46
29
  end
@@ -49,6 +32,8 @@ module Alchemy
49
32
  settings.fetch(:sizes, [])
50
33
  end
51
34
 
35
+ private
36
+
52
37
  def set_level_and_size
53
38
  self.level ||= levels.first
54
39
  self.size ||= sizes.first
@@ -38,10 +38,10 @@ module Alchemy
38
38
  upsample
39
39
  ]
40
40
 
41
+ delegate :description_for, :name, to: :picture, allow_nil: true
42
+
41
43
  def alt_text(language: Alchemy::Current.language)
42
- alt_tag.presence ||
43
- picture&.description_for(language) ||
44
- picture&.name&.humanize
44
+ alt_tag.presence || description_for(language) || name&.humanize
45
45
  end
46
46
 
47
47
  # The first 30 characters of the pictures name
@@ -51,7 +51,7 @@ module Alchemy
51
51
  # @param [Integer] max_length (30)
52
52
  #
53
53
  def preview_text(max_length = 30)
54
- picture&.name.to_s[0..max_length - 1]
54
+ name.to_s[0..max_length - 1]
55
55
  end
56
56
 
57
57
  # The picture view component with mapped options.
@@ -6,6 +6,25 @@ module Alchemy
6
6
  #
7
7
  class Select < Alchemy::Ingredient
8
8
  allow_settings %i[display_inline select_values]
9
+ allow_settings %i[display_inline select_values multiple]
10
+
11
+ serialize :value, coder: JSON
12
+
13
+ # Override value getter to handle multiple selection
14
+ def value
15
+ val = self[:value] || []
16
+ multiple? ? val : val.first
17
+ end
18
+
19
+ # Override value setter to handle multiple selection
20
+ def value=(new_value)
21
+ super(Array(new_value).compact_blank)
22
+ end
23
+
24
+ # Check if multiple selection is enabled in settings
25
+ def multiple?
26
+ settings[:multiple] == true
27
+ end
9
28
  end
10
29
  end
11
30
  end
@@ -13,7 +13,6 @@ module Alchemy::Language::Code
13
13
 
14
14
  module ClassMethods
15
15
  def find_by_code(code)
16
- return unless code.respond_to?(:split)
17
16
  codes = code.split("-")
18
17
  codes << "" if codes.length == 1
19
18
  on_current_site.find_by(
@@ -3,11 +3,12 @@
3
3
  module Alchemy
4
4
  class Node < BaseRecord
5
5
  VALID_URL_REGEX = /\A(\/|\D[a-z+\d.-]+:)/
6
+ SKIPPED_ATTRIBUTES_ON_COPY = %w[id created_at updated_at creator_id updater_id lft rgt depth parent_id]
6
7
 
7
8
  before_destroy :check_if_related_node_ingredients_present
8
9
 
9
10
  acts_as_nested_set scope: "language_id", touch: true
10
- stampable stamper_class_name: Alchemy.user_class_name
11
+ stampable stamper_class_name: Alchemy.config.user_class_name
11
12
 
12
13
  belongs_to :language, class_name: "Alchemy::Language"
13
14
  belongs_to :page, class_name: "Alchemy::Page", optional: true, inverse_of: :nodes
@@ -46,6 +47,32 @@ module Alchemy
46
47
  read_definitions_file
47
48
  end
48
49
 
50
+ def all_from_clipboard(clipboard)
51
+ return [] if clipboard.blank?
52
+
53
+ where(id: clipboard.collect { |n| n["id"] })
54
+ end
55
+
56
+ def copy_and_paste(source, new_parent, new_name)
57
+ # Prevent pasting a node as a child of itself or any of its descendants
58
+ return if new_parent && source.is_or_is_ancestor_of?(new_parent)
59
+
60
+ attributes = source.attributes.except(*SKIPPED_ATTRIBUTES_ON_COPY).merge(
61
+ name: new_name,
62
+ parent: new_parent,
63
+ language: new_parent&.language || source.language
64
+ )
65
+
66
+ node = create!(attributes)
67
+
68
+ # Copy all descendants
69
+ source.children.each do |child|
70
+ copy_and_paste(child, node, child.name)
71
+ end
72
+
73
+ node
74
+ end
75
+
49
76
  private
50
77
 
51
78
  def searchable_alchemy_resource_associations
@@ -19,9 +19,6 @@ module Alchemy
19
19
  uniqueness: {scope: [:language_id, :layoutpage], if: -> { urlname.present? }, case_sensitive: false},
20
20
  exclusion: {in: RESERVED_URLNAMES}
21
21
 
22
- before_save :set_title,
23
- if: -> { title.blank? }
24
-
25
22
  after_update :update_descendants_urlnames,
26
23
  if: :saved_change_to_urlname?
27
24
 
@@ -69,10 +66,6 @@ module Alchemy
69
66
  self[:urlname] = nested_url_name
70
67
  end
71
68
 
72
- def set_title
73
- self[:title] = name
74
- end
75
-
76
69
  # Returns the full nested urlname.
77
70
  #
78
71
  def nested_url_name
@@ -35,9 +35,13 @@ module Alchemy
35
35
  end
36
36
 
37
37
  def folded?(user_id)
38
- return unless Alchemy.user_class < ActiveRecord::Base
38
+ return unless Alchemy.config.user_class < ActiveRecord::Base
39
39
 
40
- folded_pages.where(user_id: user_id, folded: true).any?
40
+ if folded_pages.loaded?
41
+ folded_pages.any? { |p| p.folded && p.user_id == user_id }
42
+ else
43
+ folded_pages.where(user_id: user_id, folded: true).any?
44
+ end
41
45
  end
42
46
 
43
47
  # Returns an Array of Alchemy roles which are able to edit this template
@@ -95,7 +99,7 @@ module Alchemy
95
99
  def definition
96
100
  definition = PageDefinition.get(page_layout)
97
101
  if definition.nil?
98
- log_warning "Page definition for `#{page_layout}` not found. Please check `page_layouts.yml` file."
102
+ Logger.warn "Page definition for '#{page_layout}' not found. Please check page_layouts.yml file."
99
103
  return PageDefinition.new
100
104
  end
101
105
  definition
@@ -51,7 +51,7 @@ module Alchemy
51
51
  -> {
52
52
  joins(:language, :versions)
53
53
  .merge(Language.published)
54
- .merge(PageVersion.public_on(Time.current))
54
+ .merge(PageVersion.published)
55
55
  }
56
56
 
57
57
  # All pages that are a published language root
@@ -78,6 +78,18 @@ module Alchemy
78
78
  #
79
79
  scope :contentpages, -> { where(layoutpage: [false, nil]) }
80
80
 
81
+ # Returns all public contentpages that are not locked.
82
+ #
83
+ # Used for flushing all pages caches at once.
84
+ #
85
+ scope :flushables, -> { not_locked.published.contentpages }
86
+
87
+ # Returns all layoutpages that are not locked.
88
+ #
89
+ # Used for flushing all pages caches at once.
90
+ #
91
+ scope :flushable_layoutpages, -> { not_locked.layoutpages }
92
+
81
93
  # All searchable pages
82
94
  #
83
95
  scope :searchables, -> { not_restricted.published.contentpages }