diff --git a/Gemfile.lock b/Gemfile.lock index 54f6ebefdcc4d6adcab65ffaace07e8f2d3d40af..4cfbe89dcbd26526510676bc5fc525af40aeb966 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -79,17 +79,17 @@ GEM autoprefixer-rails (10.3.3.0) execjs (~> 2) aws-eventstream (1.2.0) - aws-partitions (1.515.0) - aws-sdk-core (3.121.1) + aws-partitions (1.517.0) + aws-sdk-core (3.121.3) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.239.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-kms (1.49.0) - aws-sdk-core (~> 3, >= 3.120.0) + aws-sdk-kms (1.50.0) + aws-sdk-core (~> 3, >= 3.121.2) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.103.0) - aws-sdk-core (~> 3, >= 3.120.0) + aws-sdk-s3 (1.104.0) + aws-sdk-core (~> 3, >= 3.121.2) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.4) aws-sigv4 (1.4.0) @@ -247,7 +247,7 @@ GEM public_suffix (4.0.6) puma (5.5.2) nio4r (~> 2.0) - racc (1.5.2) + racc (1.6.0) rack (2.2.3) rack-mini-profiler (2.3.3) rack (>= 1.2.0) @@ -298,7 +298,7 @@ GEM ruby-vips (2.1.3) ffi (~> 1.12) ruby2_keywords (0.0.5) - rubyzip (1.3.0) + rubyzip (2.3.2) sanitize (6.0.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -313,7 +313,7 @@ GEM sawyer (0.8.2) addressable (>= 2.3.5) faraday (> 0.8, < 2.0) - selenium-webdriver (4.0.0) + selenium-webdriver (4.0.3) childprocess (>= 0.5, < 5.0) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2) @@ -361,15 +361,16 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - webdrivers (2.4.0) + webdrivers (5.0.0) nokogiri (~> 1.6) - rubyzip (~> 1.0) + rubyzip (>= 1.3.0) + selenium-webdriver (~> 4.0) websocket-driver (0.7.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.4.2) + zeitwerk (2.5.1) PLATFORMS ruby diff --git a/app/assets/javascripts/admin/trix_direct_upload.js b/app/assets/javascripts/admin/trix_direct_upload.js deleted file mode 100644 index f622fde380044ed1514be220a77e2adb234263b4..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/admin/trix_direct_upload.js +++ /dev/null @@ -1,20 +0,0 @@ -(function() { - - addEventListener("trix-attachment-add", function(event) { - var file = event.attachment.file; - if (file) { - var upload = new window.ActiveStorage.DirectUpload(file,'/rails/active_storage/direct_uploads', window); - upload.create((error, attributes) => { - if (error) { - return false; - } else { - return event.attachment.setAttributes({ - url: `/rails/active_storage/blobs/${attributes.signed_id}/${attributes.filename}`, - href: `/rails/active_storage/blobs/${attributes.signed_id}/${attributes.filename}`, - }); - } - }); - } - }) - -})(); diff --git a/app/assets/stylesheets/commons/_actiontext.sass b/app/assets/stylesheets/commons/_actiontext.sass index 3669d72572d4c5be3e2064f5daa85601f32ab15e..266498590207b3450a25522403ccd54e7ea1bdd4 100644 --- a/app/assets/stylesheets/commons/_actiontext.sass +++ b/app/assets/stylesheets/commons/_actiontext.sass @@ -17,6 +17,9 @@ h3 font-size: 1.15rem + .attachment + text-align: initial + .attachment-gallery > action-text-attachment, > .attachment diff --git a/app/controllers/active_storage/direct_uploads_controller.rb b/app/controllers/active_storage/direct_uploads_controller.rb deleted file mode 100644 index fe237d61410167e36036c91eed19c037b729974b..0000000000000000000000000000000000000000 --- a/app/controllers/active_storage/direct_uploads_controller.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -# Creates a new blob on the server side in anticipation of a direct-to-service upload from the client side. -# When the client-side upload is completed, the signed_blob_id can be submitted as part of the form to reference -# the blob that was created up front. -class ActiveStorage::DirectUploadsController < ActiveStorage::BaseController - include ApplicationController::WithUniversity - - def create - blob = ActiveStorage::Blob.create_before_direct_upload!(**blob_args) - blob.update_column(:university_id, current_university.id) - render json: direct_upload_json(blob) - end - - private - def blob_args - params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, metadata: {}).to_h.symbolize_keys - end - - def direct_upload_json(blob) - blob.as_json(root: false, methods: :signed_id).merge(direct_upload: { - url: blob.service_url_for_direct_upload, - headers: blob.service_headers_for_direct_upload - }) - end - -end diff --git a/app/models/communication/website/imported/medium.rb b/app/models/communication/website/imported/medium.rb index ec57a1f98023f02a0ab5ffb4ecc3480bd749fc0d..ad230818055d9bfaca40ed60f3d5d334e612e17a 100644 --- a/app/models/communication/website/imported/medium.rb +++ b/app/models/communication/website/imported/medium.rb @@ -40,6 +40,7 @@ class Communication::Website::Imported::Medium < ApplicationRecord escaped_source_url = Addressable::URI.parse(value['source_url']).display_uri.to_s self.file_url = escaped_source_url self.filename = File.basename(URI(escaped_source_url).path) + # TODO unify with page and post? self.remote_created_at = DateTime.parse(value['date_gmt']) self.remote_updated_at = DateTime.parse(value['modified_gmt']) end diff --git a/app/models/communication/website/imported/page.rb b/app/models/communication/website/imported/page.rb index ad0dfe119f1a29322304b072d42f7f57effb2ac0..25f6fb93eedc267fe7c9ddb5eec79baa08f5a721 100644 --- a/app/models/communication/website/imported/page.rb +++ b/app/models/communication/website/imported/page.rb @@ -58,6 +58,8 @@ class Communication::Website::Imported::Page < ApplicationRecord self.content = value['content']['rendered'] self.parent = value['parent'] self.featured_medium = value['featured_media'] == 0 ? nil : website.media.find_by(identifier: value['featured_media']) + self.created_at = value['date_gmt'] + self.updated_at = value['modified_gmt'] end def to_s @@ -74,10 +76,14 @@ class Communication::Website::Imported::Page < ApplicationRecord self.page.title = "Untitled" self.page.save end - # TODO only if not modified since import + # Don't touch if there are local changes (this would destroy some nice work) + return if page.updated_at > updated_at + # Don't touch if there are no remote changes (this would do useless server workload) + return if page.updated_at == updated_at + puts "Update page #{page.id}" page.slug = slug page.title = Wordpress.clean title.to_s - page.description = Wordpress.clean excerpt.to_s + page.description = ActionView::Base.full_sanitizer.sanitize excerpt.to_s page.text = Wordpress.clean content.to_s page.save end diff --git a/app/models/communication/website/imported/post.rb b/app/models/communication/website/imported/post.rb index 25942d0d9316467690b1c151a7ae309d1de9f980..20cc39941bd034df58c6cd593eacd2b8fcd5570c 100644 --- a/app/models/communication/website/imported/post.rb +++ b/app/models/communication/website/imported/post.rb @@ -57,7 +57,9 @@ class Communication::Website::Imported::Post < ApplicationRecord self.title = value['title']['rendered'] self.excerpt = value['excerpt']['rendered'] self.content = value['content']['rendered'] - self.published_at = value['date'] + self.created_at = value['date_gmt'] + self.updated_at = value['modified_gmt'] + self.published_at = value['date_gmt'] self.featured_medium = website.media.find_by(identifier: value['featured_medium']) end @@ -74,12 +76,18 @@ class Communication::Website::Imported::Post < ApplicationRecord self.post.title = "Untitled" # No title yet self.post.save end - # TODO only if not modified since import + # Don't touch if there are local changes (this would destroy some nice work) + # return if post.updated_at > updated_at + # Don't touch if there are no remote changes (this would do useless server workload) + return if post.updated_at == updated_at title = Wordpress.clean title.to_s + puts "Update post #{post.id}" post.title = title unless title.blank? # If there is no title, leave it with "Untitled" post.slug = slug - post.description = Wordpress.clean excerpt.to_s + post.description = ActionView::Base.full_sanitizer.sanitize excerpt.to_s post.text = Wordpress.clean content.to_s + post.created_at = created_at + post.updated_at = updated_at post.published_at = published_at if published_at post.save end diff --git a/app/models/communication/website/imported/website.rb b/app/models/communication/website/imported/website.rb index 29f49c40860b7a93cb685d0faee407e01bed908e..7f1a202b1caf713195aa8eb3c58ccc1087b5f7bc 100644 --- a/app/models/communication/website/imported/website.rb +++ b/app/models/communication/website/imported/website.rb @@ -35,6 +35,7 @@ class Communication::Website::Imported::Website < ApplicationRecord sync_pages sync_posts end + handle_asynchronously :run!, queue: 'default' protected @@ -56,14 +57,14 @@ class Communication::Website::Imported::Website < ApplicationRecord page.data = data page.save end - pages.find_each do |page| + # The order will treat parents before children + pages.order(:url).find_each do |page| next if page.parent.blank? parent = pages.where(identifier: page.parent).first next if parent.nil? generated_page = page.page generated_page.parent = parent.page generated_page.save - # TODO save children end end diff --git a/app/models/communication/website/page.rb b/app/models/communication/website/page.rb index ce2fd2b36453e8b9a1f17b39645e8a18e6311b6f..ad4e52d14a72e60b17fc268a1a6fa6adb3af4a9f 100644 --- a/app/models/communication/website/page.rb +++ b/app/models/communication/website/page.rb @@ -34,6 +34,7 @@ class Communication::Website::Page < ApplicationRecord include WithSlug + include Communication::Website::WithGithub belongs_to :university belongs_to :website, @@ -52,20 +53,11 @@ class Communication::Website::Page < ApplicationRecord validates :title, presence: true before_save :make_path - after_save :publish_to_github scope :ordered, -> { order(:position) } scope :recent, -> { order(updated_at: :desc).limit(5) } scope :root, -> { where(parent_id: nil) } - def content - @content ||= github.read_file_at "_pages/#{id}.html" - end - - def content_without_frontmatter - frontmatter.content - end - def has_children? children.any? end @@ -76,18 +68,14 @@ class Communication::Website::Page < ApplicationRecord protected - def github - @github ||= Github.with_site(website) - end - - def frontmatter - @frontmatter ||= FrontMatterParser::Parser.new(:md).call(content) - end - def make_path self.path = "#{parent&.path}/#{slug}".gsub('//', '/') end + def github_path + "_pages/#{github_file}" + end + def publish_to_github github.publish kind: :pages, file: "#{ id }.html", diff --git a/app/models/communication/website/post.rb b/app/models/communication/website/post.rb index e7d4f38837556509c24bec9df7d5ddd4ea9c1a98..1a146d187481888ec496d019a611964221680804 100644 --- a/app/models/communication/website/post.rb +++ b/app/models/communication/website/post.rb @@ -4,10 +4,10 @@ # # id :uuid not null, primary key # description :text +# old_text :text # published :boolean default(FALSE) # published_at :datetime # slug :text -# text :text # title :string # created_at :datetime not null # updated_at :datetime not null @@ -26,6 +26,9 @@ # class Communication::Website::Post < ApplicationRecord include WithSlug + include Communication::Website::WithGithub + + has_rich_text :text belongs_to :university belongs_to :website, @@ -43,4 +46,25 @@ class Communication::Website::Post < ApplicationRecord def to_s "#{title}" end + + protected + + def github_file + "#{published_at.year}/#{published_at.month}/#{published_at.strftime "%Y-%m-%d"}-#{id}.html" + end + + def github_path + "_posts/#{github_file}" + end + + def publish_to_github + github.publish kind: :posts, + file: github_file, + title: to_s, + data: ApplicationController.render( + template: 'admin/communication/website/posts/jekyll', + layout: false, + assigns: { post: self } + ) + end end diff --git a/app/models/communication/website/with_github.rb b/app/models/communication/website/with_github.rb new file mode 100644 index 0000000000000000000000000000000000000000..ed89d0e8cb6d392621670d46b04408a09c324993 --- /dev/null +++ b/app/models/communication/website/with_github.rb @@ -0,0 +1,39 @@ +module Communication::Website::WithGithub + extend ActiveSupport::Concern + + included do + after_save :publish_to_github + end + + def content + @content ||= github.read_file_at github_path + end + + def frontmatter + @frontmatter ||= FrontMatterParser::Parser.new(:md).call(content) + end + + def content_without_frontmatter + frontmatter.content + end + + def github_file + "#{ id }.html" + end + + # Needs override + def github_path + '' + end + + protected + + def github + @github ||= Github.with_site(website) + end + + # Needs override + def publish_to_github + '' + end +end diff --git a/app/services/github.rb b/app/services/github.rb index fcad5261cbca3a9881a7325a87809cb2e8afbf48..eac664d343dd3b9efe9d555af52b5fef1220d81a 100644 --- a/app/services/github.rb +++ b/app/services/github.rb @@ -12,8 +12,8 @@ class Github def publish(kind:, file:, title:, data:) local_directory = "tmp/jekyll/#{ kind }" - FileUtils.mkdir_p local_directory local_path = "#{ local_directory }/#{ file }" + Pathname(local_path).dirname.mkpath File.write local_path, data remote_file = "_#{ kind }/#{ file }" begin @@ -30,6 +30,7 @@ class Github file: local_path, sha: sha rescue + # byebug end def send_file(attachment, path) @@ -48,6 +49,7 @@ class Github attachment.download, sha: sha rescue + # byebug end def read_file_at(path) diff --git a/app/services/wordpress.rb b/app/services/wordpress.rb index 3a053c4268d2431615e88e2e78920ee6f38f5503..2e70cde3065c40e12a47ecb998407c6fa1bfb137 100644 --- a/app/services/wordpress.rb +++ b/app/services/wordpress.rb @@ -45,7 +45,6 @@ class Wordpress posts = [] loop do batch = load_paged url, page - puts "Load page #{page}" break if batch.is_a?(Hash) || batch.empty? posts += batch page += 1 @@ -54,6 +53,7 @@ class Wordpress end def load_paged(url, page) + puts "Load #{url } on page #{page}" load_url "#{url}?page=#{page}&per_page=100" end diff --git a/app/views/active_storage/blobs/_blob.html.erb b/app/views/active_storage/blobs/_blob.html.erb new file mode 100644 index 0000000000000000000000000000000000000000..49ba357dd1d4afc63ac0bae78dbcd1a882df9e9e --- /dev/null +++ b/app/views/active_storage/blobs/_blob.html.erb @@ -0,0 +1,14 @@ +<figure class="attachment attachment--<%= blob.representable? ? "preview" : "file" %> attachment--<%= blob.filename.extension %>"> + <% if blob.representable? %> + <%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %> + <% end %> + + <figcaption class="attachment__caption"> + <% if caption = blob.try(:caption) %> + <%= caption %> + <% else %> + <span class="attachment__name"><%= blob.filename %></span> + <span class="attachment__size"><%= number_to_human_size blob.byte_size %></span> + <% end %> + </figcaption> +</figure> diff --git a/app/views/admin/application/_nav.html.erb b/app/views/admin/application/_nav.html.erb index 73dd0107954c814f018189fc65f2f7bae72dbc57..471a87ba4d8da9be368254c059c30bc461bac39c 100644 --- a/app/views/admin/application/_nav.html.erb +++ b/app/views/admin/application/_nav.html.erb @@ -5,9 +5,8 @@ <% end %> <%= render_navigation context: :admin %> - <footer class="text-center"> - <%= current_university %> - <br> + <footer class="small my-5"> + <hr> <%= link_to t('terms_of_service'), t('terms_of_service_url'), target: '_blank', rel: 'noreferrer', class: 'sidebar-link' %> <%= link_to t('privacy_policy'), t('privacy_policy_url'), target: '_blank', rel: 'noreferrer', class: 'sidebar-link' %> <%= link_to t('cookies_policy'), t('cookies_policy_url'), target: '_blank', rel: 'noreferrer', class: 'sidebar-link' %> diff --git a/app/views/admin/communication/website/pages/show.html.erb b/app/views/admin/communication/website/pages/show.html.erb index 98963fb5e351602fe42c67f13d9a832ce258560f..a989bb177b51b90bd5fcdcda932d9897450d595b 100644 --- a/app/views/admin/communication/website/pages/show.html.erb +++ b/app/views/admin/communication/website/pages/show.html.erb @@ -9,13 +9,13 @@ <div class="card-body"> <p> <strong>Description</strong> - <%= @page.description %> </p> + <%= sanitize @page.description %> <p> <strong>Text</strong> </p> - <%= raw @page.text %> + <%= sanitize @page.text %> </div> </div> </div> @@ -27,7 +27,7 @@ <table class="<%= table_classes %>"> <tbody> <tr> - <td><%= Communication::Website::Page.human_attribute_name('slug') %></td> + <td width="150"><%= Communication::Website::Page.human_attribute_name('slug') %></td> <td><%= @page.slug %></td> </tr> <tr> diff --git a/app/views/admin/communication/website/posts/_form.html.erb b/app/views/admin/communication/website/posts/_form.html.erb index a55a613a0e324b28eb8aa0772b13f04b70888383..3bf62603ef38e6905409c09c8d955f10ac110456 100644 --- a/app/views/admin/communication/website/posts/_form.html.erb +++ b/app/views/admin/communication/website/posts/_form.html.erb @@ -7,8 +7,8 @@ </div> <div class="card-body"> <%= f.input :title %> - <%= f.input :description, as: :trix_editor %> - <%= f.input :text, as: :trix_editor %> + <%= f.input :description %> + <%= f.input :text, as: :rich_text_area %> </div> </div> </div> diff --git a/app/views/admin/communication/website/posts/jekyll.html.erb b/app/views/admin/communication/website/posts/jekyll.html.erb new file mode 100644 index 0000000000000000000000000000000000000000..6f0aab1756b1d727f6aacb399a0ac2fcd37d3d52 --- /dev/null +++ b/app/views/admin/communication/website/posts/jekyll.html.erb @@ -0,0 +1,8 @@ +--- +title: "<%= @post.title %>" +date: <%= @post.published_at %> UTC +slug: "<%= @post.slug %>" +description: "<%= @post.description %>" +text: "<%= @post.text %>" +--- +<%= @post.content_without_frontmatter.html_safe %> diff --git a/app/views/admin/communication/website/posts/show.html.erb b/app/views/admin/communication/website/posts/show.html.erb index 310601d1fde4627058d955c34f41e7f626bbf7be..678c8142572da33e7a11940829d388c3355e260b 100644 --- a/app/views/admin/communication/website/posts/show.html.erb +++ b/app/views/admin/communication/website/posts/show.html.erb @@ -9,13 +9,12 @@ <div class="card-body"> <p> <strong><%= Communication::Website::Post.human_attribute_name('description') %></strong> - <%= sanitize @post.description %> </p> - + <%= sanitize @post.description %> <p> <strong><%= Communication::Website::Post.human_attribute_name('text') %></strong> </p> - <%= sanitize @post.text %> + <%= @post.text %> </div> </div> </div> @@ -27,7 +26,7 @@ <table class="<%= table_classes %>"> <tbody> <tr> - <td><%= Communication::Website::Page.human_attribute_name('slug') %></td> + <td width="150"><%= Communication::Website::Page.human_attribute_name('slug') %></td> <td><%= @post.slug %></td> </tr> <tr> @@ -37,7 +36,7 @@ <% if @post.imported_post %> <tr> <td><%= t('communication.website.imported.from') %></td> - <td><a href="<%= @post.imported_post.url %>" target="_blank"><%= @post.imported_post.url %></a></td> + <td><a href="<%= @post.imported_post.url %>" target="_blank">Original URL</a></td> </tr> <% end %> </tbody> diff --git a/config/initializers/action_text.rb b/config/initializers/action_text.rb new file mode 100644 index 0000000000000000000000000000000000000000..c0e43cbd0ea266efd23ebdcf6f782b92cfb443d1 --- /dev/null +++ b/config/initializers/action_text.rb @@ -0,0 +1,8 @@ +Rails.configuration.to_prepare do + ActionText::RichText.class_eval do + delegate :university, :university_id, to: :record + end + + ActionText::ContentHelper.allowed_tags += Rails.application.config.action_view.sanitized_allowed_tags + ActionText::ContentHelper.allowed_attributes += Rails.application.config.action_view.sanitized_allowed_attributes +end diff --git a/config/locales/communication/en.yml b/config/locales/communication/en.yml index 927ca95cbe36bb56fc0d594b22f4cd6ee3aea2c2..ae2c0416a935ecbdb37f73ccbc9da04bbc7c0c52 100644 --- a/config/locales/communication/en.yml +++ b/config/locales/communication/en.yml @@ -5,6 +5,7 @@ en: imported: from: Imported from launch: Launch import + launched: Import in progress media: file_size: File size not_imported_yet: Not imported yet diff --git a/config/locales/communication/fr.yml b/config/locales/communication/fr.yml index 5c0fbd2292f8828fef3dbef27229ed7e7c6c88b0..9ba816755606a7d15353ce07450acac0ccdc0d44 100644 --- a/config/locales/communication/fr.yml +++ b/config/locales/communication/fr.yml @@ -5,6 +5,7 @@ fr: imported: from: Importé depuis launch: Importer le site + launched: Importation en cours media: file_size: Taille du fichier not_imported_yet: Non importé pour le moment diff --git a/config/storage.yml b/config/storage.yml index 2696492e5f455203a7834127134c0efe48bbefb4..66f3ff1fac2a9e91eb39d96180e07e79e8179d47 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -13,6 +13,9 @@ scaleway: region: <%= ENV['SCALEWAY_OS_REGION'] %> bucket: <%= ENV['SCALEWAY_OS_BUCKET'] %> endpoint: <%= ENV['SCALEWAY_OS_ENDPOINT'] %> + public: true + upload: + cache_control: 'public, max-age=31536000' # Remember not to checkin your GCS keyfile to a repository # google: diff --git a/db/migrate/20211021093238_create_action_text_tables.action_text.rb b/db/migrate/20211021093238_create_action_text_tables.action_text.rb new file mode 100644 index 0000000000000000000000000000000000000000..bba5dc5076e10fd807b859cae4e18e88f73edf3f --- /dev/null +++ b/db/migrate/20211021093238_create_action_text_tables.action_text.rb @@ -0,0 +1,14 @@ +# This migration comes from action_text (originally 20180528164100) +class CreateActionTextTables < ActiveRecord::Migration[6.0] + def change + create_table :action_text_rich_texts, id: :uuid do |t| + t.string :name, null: false + t.text :body, size: :long + t.references :record, null: false, polymorphic: true, index: false, type: :uuid + + t.timestamps + + t.index [ :record_type, :record_id, :name ], name: "index_action_text_rich_texts_uniqueness", unique: true + end + end +end diff --git a/db/migrate/20211021095157_rename_text_in_communication_website_posts.rb b/db/migrate/20211021095157_rename_text_in_communication_website_posts.rb new file mode 100644 index 0000000000000000000000000000000000000000..da1a018468e54a2246f1b0343602c5c8c150a1c4 --- /dev/null +++ b/db/migrate/20211021095157_rename_text_in_communication_website_posts.rb @@ -0,0 +1,5 @@ +class RenameTextInCommunicationWebsitePosts < ActiveRecord::Migration[6.1] + def change + rename_column :communication_website_posts, :text, :old_text + end +end diff --git a/db/schema.rb b/db/schema.rb index 33335b1a6728ebcbbcefa04562c7abd95e52c004..a8b5b57149f48e017de43ae1bc14845f09c2adb6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,12 +10,22 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_10_20_090658) do +ActiveRecord::Schema.define(version: 2021_10_21_095157) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" + create_table "action_text_rich_texts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name", null: false + t.text "body" + t.string "record_type", null: false + t.uuid "record_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true + end + create_table "active_storage_attachments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -165,7 +175,7 @@ ActiveRecord::Schema.define(version: 2021_10_20_090658) do t.uuid "communication_website_id", null: false t.string "title" t.text "description" - t.text "text" + t.text "old_text" t.boolean "published", default: false t.datetime "published_at" t.datetime "created_at", precision: 6, null: false diff --git a/docs/dev/rd/action_text.md b/docs/dev/rd/action_text.md new file mode 100644 index 0000000000000000000000000000000000000000..390e8a9826ce699f9aeafec1614c005819136704 --- /dev/null +++ b/docs/dev/rd/action_text.md @@ -0,0 +1,31 @@ +# Action Text + +*PoC : Ajouter d'un `new_text` sur les `Communication::Website::Post`* + +## Ajouts des assets + +A partir de la commande `rails g action_text:install`, les styles sont importés en SASS. + +Pour la partie JS, avant Rails 7, Action Text n'est fourni qu'en module ES6 prêt pour Babel. Ce format est incompatible avec Sprockets. La solution est de récupérer sur la branche `main` de Rails, le fichier `actiontext/app/assets/javascripts/actiontext.js` et de le mettre dans les vendors pour l'importer via Sprockets. + +## Base de données + +A partir de la commande `rails g action_text:install`, le fichier de migration pour les rich texts est importé. + +On modifie bien la relation polymorphique et l'appel de `create_table` pour utiliser le type UUID. + +## Modèle + +On rajoute un `has_rich_text :new_text` sur `Communication::Website::Post`, ajout dans les permitted_params, ajout dans le formulaire avec `as: :rich_text_area`, et ajout dans le show. + +On crée un initializer pour Action Text car les `ActiveStorage::Blob` uploadés sont rattachés à un objet `ActionText::RichText`, lui-même rattaché au `Communication::Website::Post`. + +Ainsi dans l'initializer, on fait déléguer l'appel de `university` et `university_id` au `record` attaché au `ActionText::RichText`. + +## Sanitizer + +Action Text a sa propre configuration des tags et attributs autorisés dans son sanitizer. On a donc dans l'initializer `action_text.rb`, des appels pour modifier cette configuration. + +## Affichage des attachments + +TODO \ No newline at end of file diff --git a/test/fixtures/action_text/rich_texts.yml b/test/fixtures/action_text/rich_texts.yml new file mode 100644 index 0000000000000000000000000000000000000000..8b371ea604af5fba7cacf8f712528c1e12949959 --- /dev/null +++ b/test/fixtures/action_text/rich_texts.yml @@ -0,0 +1,4 @@ +# one: +# record: name_of_fixture (ClassOfFixture) +# name: content +# body: <p>In a <i>million</i> stars!</p> diff --git a/vendor/assets/javascripts/actiontext.js b/vendor/assets/javascripts/actiontext.js new file mode 100644 index 0000000000000000000000000000000000000000..792a2c1fc310fd79f41117dcd4d3c5b888837776 --- /dev/null +++ b/vendor/assets/javascripts/actiontext.js @@ -0,0 +1,880 @@ +var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; + +var activestorage = {exports: {}}; + +(function (module, exports) { +(function(global, factory) { + factory(exports) ; +})(commonjsGlobal, (function(exports) { + var sparkMd5 = { + exports: {} + }; + (function(module, exports) { + (function(factory) { + { + module.exports = factory(); + } + })((function(undefined$1) { + var hex_chr = [ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f" ]; + function md5cycle(x, k) { + var a = x[0], b = x[1], c = x[2], d = x[3]; + a += (b & c | ~b & d) + k[0] - 680876936 | 0; + a = (a << 7 | a >>> 25) + b | 0; + d += (a & b | ~a & c) + k[1] - 389564586 | 0; + d = (d << 12 | d >>> 20) + a | 0; + c += (d & a | ~d & b) + k[2] + 606105819 | 0; + c = (c << 17 | c >>> 15) + d | 0; + b += (c & d | ~c & a) + k[3] - 1044525330 | 0; + b = (b << 22 | b >>> 10) + c | 0; + a += (b & c | ~b & d) + k[4] - 176418897 | 0; + a = (a << 7 | a >>> 25) + b | 0; + d += (a & b | ~a & c) + k[5] + 1200080426 | 0; + d = (d << 12 | d >>> 20) + a | 0; + c += (d & a | ~d & b) + k[6] - 1473231341 | 0; + c = (c << 17 | c >>> 15) + d | 0; + b += (c & d | ~c & a) + k[7] - 45705983 | 0; + b = (b << 22 | b >>> 10) + c | 0; + a += (b & c | ~b & d) + k[8] + 1770035416 | 0; + a = (a << 7 | a >>> 25) + b | 0; + d += (a & b | ~a & c) + k[9] - 1958414417 | 0; + d = (d << 12 | d >>> 20) + a | 0; + c += (d & a | ~d & b) + k[10] - 42063 | 0; + c = (c << 17 | c >>> 15) + d | 0; + b += (c & d | ~c & a) + k[11] - 1990404162 | 0; + b = (b << 22 | b >>> 10) + c | 0; + a += (b & c | ~b & d) + k[12] + 1804603682 | 0; + a = (a << 7 | a >>> 25) + b | 0; + d += (a & b | ~a & c) + k[13] - 40341101 | 0; + d = (d << 12 | d >>> 20) + a | 0; + c += (d & a | ~d & b) + k[14] - 1502002290 | 0; + c = (c << 17 | c >>> 15) + d | 0; + b += (c & d | ~c & a) + k[15] + 1236535329 | 0; + b = (b << 22 | b >>> 10) + c | 0; + a += (b & d | c & ~d) + k[1] - 165796510 | 0; + a = (a << 5 | a >>> 27) + b | 0; + d += (a & c | b & ~c) + k[6] - 1069501632 | 0; + d = (d << 9 | d >>> 23) + a | 0; + c += (d & b | a & ~b) + k[11] + 643717713 | 0; + c = (c << 14 | c >>> 18) + d | 0; + b += (c & a | d & ~a) + k[0] - 373897302 | 0; + b = (b << 20 | b >>> 12) + c | 0; + a += (b & d | c & ~d) + k[5] - 701558691 | 0; + a = (a << 5 | a >>> 27) + b | 0; + d += (a & c | b & ~c) + k[10] + 38016083 | 0; + d = (d << 9 | d >>> 23) + a | 0; + c += (d & b | a & ~b) + k[15] - 660478335 | 0; + c = (c << 14 | c >>> 18) + d | 0; + b += (c & a | d & ~a) + k[4] - 405537848 | 0; + b = (b << 20 | b >>> 12) + c | 0; + a += (b & d | c & ~d) + k[9] + 568446438 | 0; + a = (a << 5 | a >>> 27) + b | 0; + d += (a & c | b & ~c) + k[14] - 1019803690 | 0; + d = (d << 9 | d >>> 23) + a | 0; + c += (d & b | a & ~b) + k[3] - 187363961 | 0; + c = (c << 14 | c >>> 18) + d | 0; + b += (c & a | d & ~a) + k[8] + 1163531501 | 0; + b = (b << 20 | b >>> 12) + c | 0; + a += (b & d | c & ~d) + k[13] - 1444681467 | 0; + a = (a << 5 | a >>> 27) + b | 0; + d += (a & c | b & ~c) + k[2] - 51403784 | 0; + d = (d << 9 | d >>> 23) + a | 0; + c += (d & b | a & ~b) + k[7] + 1735328473 | 0; + c = (c << 14 | c >>> 18) + d | 0; + b += (c & a | d & ~a) + k[12] - 1926607734 | 0; + b = (b << 20 | b >>> 12) + c | 0; + a += (b ^ c ^ d) + k[5] - 378558 | 0; + a = (a << 4 | a >>> 28) + b | 0; + d += (a ^ b ^ c) + k[8] - 2022574463 | 0; + d = (d << 11 | d >>> 21) + a | 0; + c += (d ^ a ^ b) + k[11] + 1839030562 | 0; + c = (c << 16 | c >>> 16) + d | 0; + b += (c ^ d ^ a) + k[14] - 35309556 | 0; + b = (b << 23 | b >>> 9) + c | 0; + a += (b ^ c ^ d) + k[1] - 1530992060 | 0; + a = (a << 4 | a >>> 28) + b | 0; + d += (a ^ b ^ c) + k[4] + 1272893353 | 0; + d = (d << 11 | d >>> 21) + a | 0; + c += (d ^ a ^ b) + k[7] - 155497632 | 0; + c = (c << 16 | c >>> 16) + d | 0; + b += (c ^ d ^ a) + k[10] - 1094730640 | 0; + b = (b << 23 | b >>> 9) + c | 0; + a += (b ^ c ^ d) + k[13] + 681279174 | 0; + a = (a << 4 | a >>> 28) + b | 0; + d += (a ^ b ^ c) + k[0] - 358537222 | 0; + d = (d << 11 | d >>> 21) + a | 0; + c += (d ^ a ^ b) + k[3] - 722521979 | 0; + c = (c << 16 | c >>> 16) + d | 0; + b += (c ^ d ^ a) + k[6] + 76029189 | 0; + b = (b << 23 | b >>> 9) + c | 0; + a += (b ^ c ^ d) + k[9] - 640364487 | 0; + a = (a << 4 | a >>> 28) + b | 0; + d += (a ^ b ^ c) + k[12] - 421815835 | 0; + d = (d << 11 | d >>> 21) + a | 0; + c += (d ^ a ^ b) + k[15] + 530742520 | 0; + c = (c << 16 | c >>> 16) + d | 0; + b += (c ^ d ^ a) + k[2] - 995338651 | 0; + b = (b << 23 | b >>> 9) + c | 0; + a += (c ^ (b | ~d)) + k[0] - 198630844 | 0; + a = (a << 6 | a >>> 26) + b | 0; + d += (b ^ (a | ~c)) + k[7] + 1126891415 | 0; + d = (d << 10 | d >>> 22) + a | 0; + c += (a ^ (d | ~b)) + k[14] - 1416354905 | 0; + c = (c << 15 | c >>> 17) + d | 0; + b += (d ^ (c | ~a)) + k[5] - 57434055 | 0; + b = (b << 21 | b >>> 11) + c | 0; + a += (c ^ (b | ~d)) + k[12] + 1700485571 | 0; + a = (a << 6 | a >>> 26) + b | 0; + d += (b ^ (a | ~c)) + k[3] - 1894986606 | 0; + d = (d << 10 | d >>> 22) + a | 0; + c += (a ^ (d | ~b)) + k[10] - 1051523 | 0; + c = (c << 15 | c >>> 17) + d | 0; + b += (d ^ (c | ~a)) + k[1] - 2054922799 | 0; + b = (b << 21 | b >>> 11) + c | 0; + a += (c ^ (b | ~d)) + k[8] + 1873313359 | 0; + a = (a << 6 | a >>> 26) + b | 0; + d += (b ^ (a | ~c)) + k[15] - 30611744 | 0; + d = (d << 10 | d >>> 22) + a | 0; + c += (a ^ (d | ~b)) + k[6] - 1560198380 | 0; + c = (c << 15 | c >>> 17) + d | 0; + b += (d ^ (c | ~a)) + k[13] + 1309151649 | 0; + b = (b << 21 | b >>> 11) + c | 0; + a += (c ^ (b | ~d)) + k[4] - 145523070 | 0; + a = (a << 6 | a >>> 26) + b | 0; + d += (b ^ (a | ~c)) + k[11] - 1120210379 | 0; + d = (d << 10 | d >>> 22) + a | 0; + c += (a ^ (d | ~b)) + k[2] + 718787259 | 0; + c = (c << 15 | c >>> 17) + d | 0; + b += (d ^ (c | ~a)) + k[9] - 343485551 | 0; + b = (b << 21 | b >>> 11) + c | 0; + x[0] = a + x[0] | 0; + x[1] = b + x[1] | 0; + x[2] = c + x[2] | 0; + x[3] = d + x[3] | 0; + } + function md5blk(s) { + var md5blks = [], i; + for (i = 0; i < 64; i += 4) { + md5blks[i >> 2] = s.charCodeAt(i) + (s.charCodeAt(i + 1) << 8) + (s.charCodeAt(i + 2) << 16) + (s.charCodeAt(i + 3) << 24); + } + return md5blks; + } + function md5blk_array(a) { + var md5blks = [], i; + for (i = 0; i < 64; i += 4) { + md5blks[i >> 2] = a[i] + (a[i + 1] << 8) + (a[i + 2] << 16) + (a[i + 3] << 24); + } + return md5blks; + } + function md51(s) { + var n = s.length, state = [ 1732584193, -271733879, -1732584194, 271733878 ], i, length, tail, tmp, lo, hi; + for (i = 64; i <= n; i += 64) { + md5cycle(state, md5blk(s.substring(i - 64, i))); + } + s = s.substring(i - 64); + length = s.length; + tail = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ]; + for (i = 0; i < length; i += 1) { + tail[i >> 2] |= s.charCodeAt(i) << (i % 4 << 3); + } + tail[i >> 2] |= 128 << (i % 4 << 3); + if (i > 55) { + md5cycle(state, tail); + for (i = 0; i < 16; i += 1) { + tail[i] = 0; + } + } + tmp = n * 8; + tmp = tmp.toString(16).match(/(.*?)(.{0,8})$/); + lo = parseInt(tmp[2], 16); + hi = parseInt(tmp[1], 16) || 0; + tail[14] = lo; + tail[15] = hi; + md5cycle(state, tail); + return state; + } + function md51_array(a) { + var n = a.length, state = [ 1732584193, -271733879, -1732584194, 271733878 ], i, length, tail, tmp, lo, hi; + for (i = 64; i <= n; i += 64) { + md5cycle(state, md5blk_array(a.subarray(i - 64, i))); + } + a = i - 64 < n ? a.subarray(i - 64) : new Uint8Array(0); + length = a.length; + tail = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ]; + for (i = 0; i < length; i += 1) { + tail[i >> 2] |= a[i] << (i % 4 << 3); + } + tail[i >> 2] |= 128 << (i % 4 << 3); + if (i > 55) { + md5cycle(state, tail); + for (i = 0; i < 16; i += 1) { + tail[i] = 0; + } + } + tmp = n * 8; + tmp = tmp.toString(16).match(/(.*?)(.{0,8})$/); + lo = parseInt(tmp[2], 16); + hi = parseInt(tmp[1], 16) || 0; + tail[14] = lo; + tail[15] = hi; + md5cycle(state, tail); + return state; + } + function rhex(n) { + var s = "", j; + for (j = 0; j < 4; j += 1) { + s += hex_chr[n >> j * 8 + 4 & 15] + hex_chr[n >> j * 8 & 15]; + } + return s; + } + function hex(x) { + var i; + for (i = 0; i < x.length; i += 1) { + x[i] = rhex(x[i]); + } + return x.join(""); + } + if (hex(md51("hello")) !== "5d41402abc4b2a76b9719d911017c592") ; + if (typeof ArrayBuffer !== "undefined" && !ArrayBuffer.prototype.slice) { + (function() { + function clamp(val, length) { + val = val | 0 || 0; + if (val < 0) { + return Math.max(val + length, 0); + } + return Math.min(val, length); + } + ArrayBuffer.prototype.slice = function(from, to) { + var length = this.byteLength, begin = clamp(from, length), end = length, num, target, targetArray, sourceArray; + if (to !== undefined$1) { + end = clamp(to, length); + } + if (begin > end) { + return new ArrayBuffer(0); + } + num = end - begin; + target = new ArrayBuffer(num); + targetArray = new Uint8Array(target); + sourceArray = new Uint8Array(this, begin, num); + targetArray.set(sourceArray); + return target; + }; + })(); + } + function toUtf8(str) { + if (/[\u0080-\uFFFF]/.test(str)) { + str = unescape(encodeURIComponent(str)); + } + return str; + } + function utf8Str2ArrayBuffer(str, returnUInt8Array) { + var length = str.length, buff = new ArrayBuffer(length), arr = new Uint8Array(buff), i; + for (i = 0; i < length; i += 1) { + arr[i] = str.charCodeAt(i); + } + return returnUInt8Array ? arr : buff; + } + function arrayBuffer2Utf8Str(buff) { + return String.fromCharCode.apply(null, new Uint8Array(buff)); + } + function concatenateArrayBuffers(first, second, returnUInt8Array) { + var result = new Uint8Array(first.byteLength + second.byteLength); + result.set(new Uint8Array(first)); + result.set(new Uint8Array(second), first.byteLength); + return returnUInt8Array ? result : result.buffer; + } + function hexToBinaryString(hex) { + var bytes = [], length = hex.length, x; + for (x = 0; x < length - 1; x += 2) { + bytes.push(parseInt(hex.substr(x, 2), 16)); + } + return String.fromCharCode.apply(String, bytes); + } + function SparkMD5() { + this.reset(); + } + SparkMD5.prototype.append = function(str) { + this.appendBinary(toUtf8(str)); + return this; + }; + SparkMD5.prototype.appendBinary = function(contents) { + this._buff += contents; + this._length += contents.length; + var length = this._buff.length, i; + for (i = 64; i <= length; i += 64) { + md5cycle(this._hash, md5blk(this._buff.substring(i - 64, i))); + } + this._buff = this._buff.substring(i - 64); + return this; + }; + SparkMD5.prototype.end = function(raw) { + var buff = this._buff, length = buff.length, i, tail = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ], ret; + for (i = 0; i < length; i += 1) { + tail[i >> 2] |= buff.charCodeAt(i) << (i % 4 << 3); + } + this._finish(tail, length); + ret = hex(this._hash); + if (raw) { + ret = hexToBinaryString(ret); + } + this.reset(); + return ret; + }; + SparkMD5.prototype.reset = function() { + this._buff = ""; + this._length = 0; + this._hash = [ 1732584193, -271733879, -1732584194, 271733878 ]; + return this; + }; + SparkMD5.prototype.getState = function() { + return { + buff: this._buff, + length: this._length, + hash: this._hash.slice() + }; + }; + SparkMD5.prototype.setState = function(state) { + this._buff = state.buff; + this._length = state.length; + this._hash = state.hash; + return this; + }; + SparkMD5.prototype.destroy = function() { + delete this._hash; + delete this._buff; + delete this._length; + }; + SparkMD5.prototype._finish = function(tail, length) { + var i = length, tmp, lo, hi; + tail[i >> 2] |= 128 << (i % 4 << 3); + if (i > 55) { + md5cycle(this._hash, tail); + for (i = 0; i < 16; i += 1) { + tail[i] = 0; + } + } + tmp = this._length * 8; + tmp = tmp.toString(16).match(/(.*?)(.{0,8})$/); + lo = parseInt(tmp[2], 16); + hi = parseInt(tmp[1], 16) || 0; + tail[14] = lo; + tail[15] = hi; + md5cycle(this._hash, tail); + }; + SparkMD5.hash = function(str, raw) { + return SparkMD5.hashBinary(toUtf8(str), raw); + }; + SparkMD5.hashBinary = function(content, raw) { + var hash = md51(content), ret = hex(hash); + return raw ? hexToBinaryString(ret) : ret; + }; + SparkMD5.ArrayBuffer = function() { + this.reset(); + }; + SparkMD5.ArrayBuffer.prototype.append = function(arr) { + var buff = concatenateArrayBuffers(this._buff.buffer, arr, true), length = buff.length, i; + this._length += arr.byteLength; + for (i = 64; i <= length; i += 64) { + md5cycle(this._hash, md5blk_array(buff.subarray(i - 64, i))); + } + this._buff = i - 64 < length ? new Uint8Array(buff.buffer.slice(i - 64)) : new Uint8Array(0); + return this; + }; + SparkMD5.ArrayBuffer.prototype.end = function(raw) { + var buff = this._buff, length = buff.length, tail = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ], i, ret; + for (i = 0; i < length; i += 1) { + tail[i >> 2] |= buff[i] << (i % 4 << 3); + } + this._finish(tail, length); + ret = hex(this._hash); + if (raw) { + ret = hexToBinaryString(ret); + } + this.reset(); + return ret; + }; + SparkMD5.ArrayBuffer.prototype.reset = function() { + this._buff = new Uint8Array(0); + this._length = 0; + this._hash = [ 1732584193, -271733879, -1732584194, 271733878 ]; + return this; + }; + SparkMD5.ArrayBuffer.prototype.getState = function() { + var state = SparkMD5.prototype.getState.call(this); + state.buff = arrayBuffer2Utf8Str(state.buff); + return state; + }; + SparkMD5.ArrayBuffer.prototype.setState = function(state) { + state.buff = utf8Str2ArrayBuffer(state.buff, true); + return SparkMD5.prototype.setState.call(this, state); + }; + SparkMD5.ArrayBuffer.prototype.destroy = SparkMD5.prototype.destroy; + SparkMD5.ArrayBuffer.prototype._finish = SparkMD5.prototype._finish; + SparkMD5.ArrayBuffer.hash = function(arr, raw) { + var hash = md51_array(new Uint8Array(arr)), ret = hex(hash); + return raw ? hexToBinaryString(ret) : ret; + }; + return SparkMD5; + })); + })(sparkMd5); + var SparkMD5 = sparkMd5.exports; + const fileSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice; + class FileChecksum { + static create(file, callback) { + const instance = new FileChecksum(file); + instance.create(callback); + } + constructor(file) { + this.file = file; + this.chunkSize = 2097152; + this.chunkCount = Math.ceil(this.file.size / this.chunkSize); + this.chunkIndex = 0; + } + create(callback) { + this.callback = callback; + this.md5Buffer = new SparkMD5.ArrayBuffer; + this.fileReader = new FileReader; + this.fileReader.addEventListener("load", (event => this.fileReaderDidLoad(event))); + this.fileReader.addEventListener("error", (event => this.fileReaderDidError(event))); + this.readNextChunk(); + } + fileReaderDidLoad(event) { + this.md5Buffer.append(event.target.result); + if (!this.readNextChunk()) { + const binaryDigest = this.md5Buffer.end(true); + const base64digest = btoa(binaryDigest); + this.callback(null, base64digest); + } + } + fileReaderDidError(event) { + this.callback(`Error reading ${this.file.name}`); + } + readNextChunk() { + if (this.chunkIndex < this.chunkCount || this.chunkIndex == 0 && this.chunkCount == 0) { + const start = this.chunkIndex * this.chunkSize; + const end = Math.min(start + this.chunkSize, this.file.size); + const bytes = fileSlice.call(this.file, start, end); + this.fileReader.readAsArrayBuffer(bytes); + this.chunkIndex++; + return true; + } else { + return false; + } + } + } + function getMetaValue(name) { + const element = findElement(document.head, `meta[name="${name}"]`); + if (element) { + return element.getAttribute("content"); + } + } + function findElements(root, selector) { + if (typeof root == "string") { + selector = root; + root = document; + } + const elements = root.querySelectorAll(selector); + return toArray(elements); + } + function findElement(root, selector) { + if (typeof root == "string") { + selector = root; + root = document; + } + return root.querySelector(selector); + } + function dispatchEvent(element, type, eventInit = {}) { + const {disabled: disabled} = element; + const {bubbles: bubbles, cancelable: cancelable, detail: detail} = eventInit; + const event = document.createEvent("Event"); + event.initEvent(type, bubbles || true, cancelable || true); + event.detail = detail || {}; + try { + element.disabled = false; + element.dispatchEvent(event); + } finally { + element.disabled = disabled; + } + return event; + } + function toArray(value) { + if (Array.isArray(value)) { + return value; + } else if (Array.from) { + return Array.from(value); + } else { + return [].slice.call(value); + } + } + class BlobRecord { + constructor(file, checksum, url) { + this.file = file; + this.attributes = { + filename: file.name, + content_type: file.type || "application/octet-stream", + byte_size: file.size, + checksum: checksum + }; + this.xhr = new XMLHttpRequest; + this.xhr.open("POST", url, true); + this.xhr.responseType = "json"; + this.xhr.setRequestHeader("Content-Type", "application/json"); + this.xhr.setRequestHeader("Accept", "application/json"); + this.xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); + const csrfToken = getMetaValue("csrf-token"); + if (csrfToken != undefined) { + this.xhr.setRequestHeader("X-CSRF-Token", csrfToken); + } + this.xhr.addEventListener("load", (event => this.requestDidLoad(event))); + this.xhr.addEventListener("error", (event => this.requestDidError(event))); + } + get status() { + return this.xhr.status; + } + get response() { + const {responseType: responseType, response: response} = this.xhr; + if (responseType == "json") { + return response; + } else { + return JSON.parse(response); + } + } + create(callback) { + this.callback = callback; + this.xhr.send(JSON.stringify({ + blob: this.attributes + })); + } + requestDidLoad(event) { + if (this.status >= 200 && this.status < 300) { + const {response: response} = this; + const {direct_upload: direct_upload} = response; + delete response.direct_upload; + this.attributes = response; + this.directUploadData = direct_upload; + this.callback(null, this.toJSON()); + } else { + this.requestDidError(event); + } + } + requestDidError(event) { + this.callback(`Error creating Blob for "${this.file.name}". Status: ${this.status}`); + } + toJSON() { + const result = {}; + for (const key in this.attributes) { + result[key] = this.attributes[key]; + } + return result; + } + } + class BlobUpload { + constructor(blob) { + this.blob = blob; + this.file = blob.file; + const {url: url, headers: headers} = blob.directUploadData; + this.xhr = new XMLHttpRequest; + this.xhr.open("PUT", url, true); + this.xhr.responseType = "text"; + for (const key in headers) { + this.xhr.setRequestHeader(key, headers[key]); + } + this.xhr.addEventListener("load", (event => this.requestDidLoad(event))); + this.xhr.addEventListener("error", (event => this.requestDidError(event))); + } + create(callback) { + this.callback = callback; + this.xhr.send(this.file.slice()); + } + requestDidLoad(event) { + const {status: status, response: response} = this.xhr; + if (status >= 200 && status < 300) { + this.callback(null, response); + } else { + this.requestDidError(event); + } + } + requestDidError(event) { + this.callback(`Error storing "${this.file.name}". Status: ${this.xhr.status}`); + } + } + let id = 0; + class DirectUpload { + constructor(file, url, delegate) { + this.id = ++id; + this.file = file; + this.url = url; + this.delegate = delegate; + } + create(callback) { + FileChecksum.create(this.file, ((error, checksum) => { + if (error) { + callback(error); + return; + } + const blob = new BlobRecord(this.file, checksum, this.url); + notify(this.delegate, "directUploadWillCreateBlobWithXHR", blob.xhr); + blob.create((error => { + if (error) { + callback(error); + } else { + const upload = new BlobUpload(blob); + notify(this.delegate, "directUploadWillStoreFileWithXHR", upload.xhr); + upload.create((error => { + if (error) { + callback(error); + } else { + callback(null, blob.toJSON()); + } + })); + } + })); + })); + } + } + function notify(object, methodName, ...messages) { + if (object && typeof object[methodName] == "function") { + return object[methodName](...messages); + } + } + class DirectUploadController { + constructor(input, file) { + this.input = input; + this.file = file; + this.directUpload = new DirectUpload(this.file, this.url, this); + this.dispatch("initialize"); + } + start(callback) { + const hiddenInput = document.createElement("input"); + hiddenInput.type = "hidden"; + hiddenInput.name = this.input.name; + this.input.insertAdjacentElement("beforebegin", hiddenInput); + this.dispatch("start"); + this.directUpload.create(((error, attributes) => { + if (error) { + hiddenInput.parentNode.removeChild(hiddenInput); + this.dispatchError(error); + } else { + hiddenInput.value = attributes.signed_id; + } + this.dispatch("end"); + callback(error); + })); + } + uploadRequestDidProgress(event) { + const progress = event.loaded / event.total * 100; + if (progress) { + this.dispatch("progress", { + progress: progress + }); + } + } + get url() { + return this.input.getAttribute("data-direct-upload-url"); + } + dispatch(name, detail = {}) { + detail.file = this.file; + detail.id = this.directUpload.id; + return dispatchEvent(this.input, `direct-upload:${name}`, { + detail: detail + }); + } + dispatchError(error) { + const event = this.dispatch("error", { + error: error + }); + if (!event.defaultPrevented) { + alert(error); + } + } + directUploadWillCreateBlobWithXHR(xhr) { + this.dispatch("before-blob-request", { + xhr: xhr + }); + } + directUploadWillStoreFileWithXHR(xhr) { + this.dispatch("before-storage-request", { + xhr: xhr + }); + xhr.upload.addEventListener("progress", (event => this.uploadRequestDidProgress(event))); + } + } + const inputSelector = "input[type=file][data-direct-upload-url]:not([disabled])"; + class DirectUploadsController { + constructor(form) { + this.form = form; + this.inputs = findElements(form, inputSelector).filter((input => input.files.length)); + } + start(callback) { + const controllers = this.createDirectUploadControllers(); + const startNextController = () => { + const controller = controllers.shift(); + if (controller) { + controller.start((error => { + if (error) { + callback(error); + this.dispatch("end"); + } else { + startNextController(); + } + })); + } else { + callback(); + this.dispatch("end"); + } + }; + this.dispatch("start"); + startNextController(); + } + createDirectUploadControllers() { + const controllers = []; + this.inputs.forEach((input => { + toArray(input.files).forEach((file => { + const controller = new DirectUploadController(input, file); + controllers.push(controller); + })); + })); + return controllers; + } + dispatch(name, detail = {}) { + return dispatchEvent(this.form, `direct-uploads:${name}`, { + detail: detail + }); + } + } + const processingAttribute = "data-direct-uploads-processing"; + const submitButtonsByForm = new WeakMap; + let started = false; + function start() { + if (!started) { + started = true; + document.addEventListener("click", didClick, true); + document.addEventListener("submit", didSubmitForm, true); + document.addEventListener("ajax:before", didSubmitRemoteElement); + } + } + function didClick(event) { + const {target: target} = event; + if ((target.tagName == "INPUT" || target.tagName == "BUTTON") && target.type == "submit" && target.form) { + submitButtonsByForm.set(target.form, target); + } + } + function didSubmitForm(event) { + handleFormSubmissionEvent(event); + } + function didSubmitRemoteElement(event) { + if (event.target.tagName == "FORM") { + handleFormSubmissionEvent(event); + } + } + function handleFormSubmissionEvent(event) { + const form = event.target; + if (form.hasAttribute(processingAttribute)) { + event.preventDefault(); + return; + } + const controller = new DirectUploadsController(form); + const {inputs: inputs} = controller; + if (inputs.length) { + event.preventDefault(); + form.setAttribute(processingAttribute, ""); + inputs.forEach(disable); + controller.start((error => { + form.removeAttribute(processingAttribute); + if (error) { + inputs.forEach(enable); + } else { + submitForm(form); + } + })); + } + } + function submitForm(form) { + let button = submitButtonsByForm.get(form) || findElement(form, "input[type=submit], button[type=submit]"); + if (button) { + const {disabled: disabled} = button; + button.disabled = false; + button.focus(); + button.click(); + button.disabled = disabled; + } else { + button = document.createElement("input"); + button.type = "submit"; + button.style.display = "none"; + form.appendChild(button); + button.click(); + form.removeChild(button); + } + submitButtonsByForm.delete(form); + } + function disable(input) { + input.disabled = true; + } + function enable(input) { + input.disabled = false; + } + function autostart() { + if (window.ActiveStorage) { + start(); + } + } + setTimeout(autostart, 1); + exports.DirectUpload = DirectUpload; + exports.start = start; + Object.defineProperty(exports, "__esModule", { + value: true + }); +})); +}(activestorage, activestorage.exports)); + +class AttachmentUpload { + constructor(attachment, element) { + this.attachment = attachment; + this.element = element; + this.directUpload = new activestorage.exports.DirectUpload(attachment.file, this.directUploadUrl, this); + } + + start() { + this.directUpload.create(this.directUploadDidComplete.bind(this)); + } + + directUploadWillStoreFileWithXHR(xhr) { + xhr.upload.addEventListener("progress", event => { + const progress = event.loaded / event.total * 100; + this.attachment.setUploadProgress(progress); + }); + } + + directUploadDidComplete(error, attributes) { + if (error) { + throw new Error(`Direct upload failed: ${error}`) + } + + this.attachment.setAttributes({ + sgid: attributes.attachable_sgid, + url: this.createBlobUrl(attributes.signed_id, attributes.filename) + }); + } + + createBlobUrl(signedId, filename) { + return this.blobUrlTemplate + .replace(":signed_id", signedId) + .replace(":filename", encodeURIComponent(filename)) + } + + get directUploadUrl() { + return this.element.dataset.directUploadUrl + } + + get blobUrlTemplate() { + return this.element.dataset.blobUrlTemplate + } +} + +addEventListener("trix-attachment-add", event => { + const { attachment, target } = event; + + if (attachment.file) { + const upload = new AttachmentUpload(attachment, target); + upload.start(); + } +});