From 104eed8b3041ff0e8431eef208a01ef6451e2fc2 Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Fri, 30 Aug 2024 12:02:02 -0400 Subject: [PATCH 01/27] Upgrade storyblok-richtext-renderer and add custom marks/nodes (#512) --- Gemfile | 3 +- Gemfile.lock | 8 ++++-- lib/storyblok_richtext/marks/color.rb | 20 +++++++++++++ lib/storyblok_richtext/marks/em.rb | 13 +++++++++ lib/storyblok_richtext/marks/font_family.rb | 20 +++++++++++++ lib/storyblok_richtext/marks/font_size.rb | 20 +++++++++++++ lib/storyblok_richtext/marks/strikethrough.rb | 14 ++++++++++ lib/storyblok_richtext/marks/text_style.rb | 24 ++++++++++++++++ lib/storyblok_richtext/nodes/image.rb | 22 +++++++++++++++ lib/storyblok_richtext/nodes/paragraph.rb | 28 +++++++++++++++++++ 10 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 lib/storyblok_richtext/marks/color.rb create mode 100644 lib/storyblok_richtext/marks/em.rb create mode 100644 lib/storyblok_richtext/marks/font_family.rb create mode 100644 lib/storyblok_richtext/marks/font_size.rb create mode 100644 lib/storyblok_richtext/marks/strikethrough.rb create mode 100644 lib/storyblok_richtext/marks/text_style.rb create mode 100644 lib/storyblok_richtext/nodes/image.rb create mode 100644 lib/storyblok_richtext/nodes/paragraph.rb diff --git a/Gemfile b/Gemfile index 108ce1ed..db3520b8 100644 --- a/Gemfile +++ b/Gemfile @@ -29,7 +29,7 @@ gem 'rack-cors', :require => 'rack/cors' gem 'pg_search' gem 'figaro' gem 'open-uri' -gem 'storyblok-richtext-renderer', github: 'performant-software/storyblok-ruby-richtext-renderer', ref: '0a6c2e8e81560311569d49d06c0e32abd0effcd5' +gem 'storyblok-richtext-renderer', github: 'performant-software/storyblok-ruby-richtext-renderer', ref: 'bef6903146426e01175887eb92a75bf9bac4c3cb' gem 'sidekiq', '~>6.5.1' gem 'sidekiq-status', '~>2.1.1' @@ -54,3 +54,4 @@ gem 'mutex_m', '~> 0.2.0' gem 'bigdecimal', '~> 3.1' gem 'psych', '< 4' gem "dotenv-rails", "~> 2.8" +gem "rubyzip", "~> 2.3" diff --git a/Gemfile.lock b/Gemfile.lock index 706dd91b..63c4c034 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,9 @@ GIT remote: https://github.com/performant-software/storyblok-ruby-richtext-renderer.git - revision: 0a6c2e8e81560311569d49d06c0e32abd0effcd5 - ref: 0a6c2e8e81560311569d49d06c0e32abd0effcd5 + revision: bef6903146426e01175887eb92a75bf9bac4c3cb + ref: bef6903146426e01175887eb92a75bf9bac4c3cb specs: - storyblok-richtext-renderer (0.0.6) + storyblok-richtext-renderer (0.0.10) GEM remote: https://rubygems.org/ @@ -240,6 +240,7 @@ GEM ruby-vips (2.2.2) ffi (~> 1.12) logger + rubyzip (2.3.2) sidekiq (6.5.12) connection_pool (>= 2.2.5, < 3) rack (~> 2.0) @@ -313,6 +314,7 @@ DEPENDENCIES puma (~> 4.3) rack-cors rails (~> 6.1) + rubyzip (~> 2.3) sidekiq (~> 6.5.1) sidekiq-status (~> 2.1.1) spring diff --git a/lib/storyblok_richtext/marks/color.rb b/lib/storyblok_richtext/marks/color.rb new file mode 100644 index 00000000..77c9dbf2 --- /dev/null +++ b/lib/storyblok_richtext/marks/color.rb @@ -0,0 +1,20 @@ +module Storyblok::Richtext + module Marks + class Color < Mark + + def matching + @node['type'] === 'color' + end + + def tag + attrs = { + style: "color:#{@node['attrs']['color']};" + } + [{ + tag: 'span', + attrs: attrs + }] + end + end + end +end diff --git a/lib/storyblok_richtext/marks/em.rb b/lib/storyblok_richtext/marks/em.rb new file mode 100644 index 00000000..fb44ccca --- /dev/null +++ b/lib/storyblok_richtext/marks/em.rb @@ -0,0 +1,13 @@ +module Storyblok::Richtext + module Marks + class Em < Mark + def matching + @node['type'] === 'em' + end + + def tag + 'em' + end + end + end +end diff --git a/lib/storyblok_richtext/marks/font_family.rb b/lib/storyblok_richtext/marks/font_family.rb new file mode 100644 index 00000000..0af0e5f7 --- /dev/null +++ b/lib/storyblok_richtext/marks/font_family.rb @@ -0,0 +1,20 @@ +module Storyblok::Richtext + module Marks + class FontFamily < Mark + + def matching + @node['type'] === 'fontFamily' + end + + def tag + attrs = { + style: "font-family:#{@node['attrs']['fontFamily']};" + } + [{ + tag: 'span', + attrs: attrs + }] + end + end + end +end diff --git a/lib/storyblok_richtext/marks/font_size.rb b/lib/storyblok_richtext/marks/font_size.rb new file mode 100644 index 00000000..c6deff36 --- /dev/null +++ b/lib/storyblok_richtext/marks/font_size.rb @@ -0,0 +1,20 @@ +module Storyblok::Richtext + module Marks + class FontSize < Mark + + def matching + @node['type'] === 'fontSize' + end + + def tag + attrs = { + style: "font-size:#{@node['attrs']['fontSize']};" + } + [{ + tag: 'span', + attrs: attrs + }] + end + end + end +end diff --git a/lib/storyblok_richtext/marks/strikethrough.rb b/lib/storyblok_richtext/marks/strikethrough.rb new file mode 100644 index 00000000..19c9babf --- /dev/null +++ b/lib/storyblok_richtext/marks/strikethrough.rb @@ -0,0 +1,14 @@ +module Storyblok::Richtext + module Marks + class Strikethrough < Mark + + def matching + @node['type'] === 'strikethrough' + end + + def tag + 's' + end + end + end +end diff --git a/lib/storyblok_richtext/marks/text_style.rb b/lib/storyblok_richtext/marks/text_style.rb new file mode 100644 index 00000000..713982c5 --- /dev/null +++ b/lib/storyblok_richtext/marks/text_style.rb @@ -0,0 +1,24 @@ +module Storyblok::Richtext + module Marks + class TextStyle < Mark + + def matching + @node['type'] === 'textStyle' + end + + def tag + color = "color:#{@node['attrs']['color']};" + font_size = "font-size:#{@node['attrs']['fontSize']};" + font_family = "font-family:#{@node['attrs']['fontFamily']};" + text_decoration = "text-decoration:#{@node['attrs']['textDecoration']};" + attrs = { + style: "#{color} #{font_size} #{font_family} #{text_decoration}" + } + [{ + tag: 'span', + attrs: attrs + }] + end + end + end +end diff --git a/lib/storyblok_richtext/nodes/image.rb b/lib/storyblok_richtext/nodes/image.rb new file mode 100644 index 00000000..8c92ea2a --- /dev/null +++ b/lib/storyblok_richtext/nodes/image.rb @@ -0,0 +1,22 @@ +module Storyblok::Richtext + module Nodes + class Image < Node + + def matching + @node['type'] === 'image' + end + + def single_tag + attrs = {} + if !@node['attrs'].nil? + attrs = @node['attrs'].slice('src', 'alt', 'title') + attrs['style'] = "width: #{@node['attrs']['width']};" + end + return [{ + tag: "img", + attrs: attrs + }] + end + end + end +end diff --git a/lib/storyblok_richtext/nodes/paragraph.rb b/lib/storyblok_richtext/nodes/paragraph.rb new file mode 100644 index 00000000..3d1e2fd5 --- /dev/null +++ b/lib/storyblok_richtext/nodes/paragraph.rb @@ -0,0 +1,28 @@ +module Storyblok::Richtext + module Nodes + class Paragraph < Node + + def matching + @node['type'] === 'paragraph' + end + + def tag + if @node['attrs'] + text_indent = @node['attrs']['indented'] ? '3rem' : '0' + text_indent = "text-indent: #{text_indent};" + margin_left = "margin-left: #{(@node['attrs']['indentLevel'] || 0) * 48}px;" + line_height = "line-height: #{@node['attrs']['lineHeight']};" + attrs = { + style: "#{text_indent} #{margin_left} #{line_height}" + } + [{ + tag: 'p', + attrs: attrs, + }] + else + 'p' + end + end + end + end +end From eddbb48cf7e59196b81dd2c3f4afee4655f623c2 Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Tue, 3 Sep 2024 17:17:11 -0400 Subject: [PATCH 02/27] Basic html export (no images) --- app/models/concerns/exportable.rb | 150 ++++++++++++++++++++++ app/models/project.rb | 5 + lib/storyblok_richtext/marks/highlight.rb | 22 ++++ 3 files changed, 177 insertions(+) create mode 100644 app/models/concerns/exportable.rb create mode 100644 lib/storyblok_richtext/marks/highlight.rb diff --git a/app/models/concerns/exportable.rb b/app/models/concerns/exportable.rb new file mode 100644 index 00000000..818686d0 --- /dev/null +++ b/app/models/concerns/exportable.rb @@ -0,0 +1,150 @@ +require 'zip' +require 'storyblok/richtext' +require 'storyblok_richtext/marks/color' +require 'storyblok_richtext/marks/em' +require 'storyblok_richtext/marks/font_family' +require 'storyblok_richtext/marks/font_size' +require 'storyblok_richtext/marks/highlight' +require 'storyblok_richtext/marks/strikethrough' +require 'storyblok_richtext/marks/text_style' +require 'storyblok_richtext/nodes/image' +require 'storyblok_richtext/nodes/paragraph' + +module Exportable + extend ActiveSupport::Concern + + def sanitize_filename(filename) + filename.gsub(/[\x00\/\\:\*\?\"<>\|]/, "_") + end + + def recursively_deflate_folder(folder, zipfile, zipfile_path, index_html, depth) + zipfile.mkdir(zipfile_path) + subdir = folder.contents_children + self.write_zip_entries(subdir, zipfile_path, zipfile, index_html, depth + 1) + end + + def get_path(document_id, current_depth) + document = Document.find(document_id) + filename = sanitize_filename(document.title).strip.parameterize + path_segments = ["#{filename}.html"] + while document[:parent_type] != "Project" + document = document.parent + path_segments.unshift(sanitize_filename(document[:title]).strip) + end + back_out = current_depth > 0 ? Array.new(current_depth, "..").join("/") + "/" : "" + path = back_out + path_segments.join("/") + return path + end + + def write_zip_entries(entries, path, zipfile, index_html, depth) + entries.each do |child| + name = sanitize_filename(child.title).strip + zipfile_path = path == '' ? name : File.join(path, name) + if child.instance_of? DocumentFolder or child.contents_children.length() > 0 + index_html.write("
  • #{child.title}") + index_html.write("
      ") + self.recursively_deflate_folder(child, zipfile, zipfile_path, index_html, depth) + index_html.write("
  • ") + end + if not child.instance_of? DocumentFolder + # use parameterize on basename to produce well-formed URLs + zipfile_path = Pathname.new(zipfile_path) + dir, base = zipfile_path.split + base = Pathname.new(base.to_s.parameterize) + zipfile_path = dir.join(base) + zipfile_path = "#{zipfile_path.to_s}.html" + + zipfile.get_output_stream(zipfile_path) { |html_outfile| + html_outfile.write('') + html_outfile.write("") + if child.document_kind == "canvas" + # images page + if child[:content] && child[:content]["tileSources"] + for img_url in child.image_urls + html_outfile.write("#{img_url}
    ") + end + else + for img_url in child.image_urls + html_outfile.write("
    ") + end + end + elsif child.document_kind == "text" + # text page + # TODO: handle multicolumn layout + # render text documents from prosemirror/storyblok to html + renderer = Storyblok::Richtext::HtmlRenderer.new + renderer.add_mark(Storyblok::Richtext::Marks::Color) + renderer.add_mark(Storyblok::Richtext::Marks::Em) + renderer.add_mark(Storyblok::Richtext::Marks::FontFamily) + renderer.add_mark(Storyblok::Richtext::Marks::FontSize) + renderer.add_mark(Storyblok::Richtext::Marks::Highlight) + renderer.add_mark(Storyblok::Richtext::Marks::Strikethrough) + renderer.add_mark(Storyblok::Richtext::Marks::TextStyle) + renderer.add_node(Storyblok::Richtext::Nodes::Image) + renderer.add_node(Storyblok::Richtext::Nodes::Paragraph) + + html = renderer.render(child[:content]) + html_outfile.write(html) + else + # TODO: determine if there are any of these + html_outfile.write(child.document_kind) + end + + # add highlights to footer + if child.highlight_map.present? + styles = [] + html_outfile.write("
      ") + child.highlight_map.each do |highlight| + # list of links on highlight + html_outfile.write("
    1. #{highlight[1].title || highlight[1].excerpt}") + html_outfile.write("
        ") + highlight[1].links_to.each do |link| + if link[:document_id].present? + html_outfile.write("
      1. #{link[:title]}
      2. ") + else + html_outfile.write("
      3. #{link[:title]}
      4. ") + end + end + html_outfile.write("
      ") + html_outfile.write("
    2. ") + # add style + styles << "a[class*=\"#{highlight[0]}\"] { color: black; background-color: #{highlight[1].color}; }" + end + html_outfile.write("
    ") + html_outfile.write("") + end + html_outfile.write("") + } + index_html.write("
  • #{child.title}
  • ") + end + end + end + + def front_matter + [ + '', + "", + "

    #{self.title}

    ", + "") + index_html.write("") + } + end + end +end \ No newline at end of file diff --git a/app/models/project.rb b/app/models/project.rb index 06bd1950..3b3da690 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -11,6 +11,7 @@ class Project < ApplicationRecord before_destroy :destroyer include TreeNode + include Exportable def destroyer self.contents_children.each { |child| @@ -67,4 +68,8 @@ def migrate_to_position! folder.migrate_to_position! } end + + def export_html_zip + self.export + end end diff --git a/lib/storyblok_richtext/marks/highlight.rb b/lib/storyblok_richtext/marks/highlight.rb new file mode 100644 index 00000000..66b6d0dd --- /dev/null +++ b/lib/storyblok_richtext/marks/highlight.rb @@ -0,0 +1,22 @@ +module Storyblok::Richtext + module Marks + class Highlight < Mark + + def matching + @node['type'] === 'highlight' + end + + def tag + highlight_uid = @node['attrs']['highlightUid'] + classname = "dm-highlight #{highlight_uid}" + [{ + tag: 'a', + attrs: { + class: classname, + href: "##{highlight_uid}", + } + }] + end + end + end +end From 6a17d639da359b1a1b944482a8110a7f38aeeda0 Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Wed, 4 Sep 2024 15:08:23 -0400 Subject: [PATCH 03/27] Use template for index html (#511) --- app/models/concerns/exportable.rb | 98 ++++++++++++++----------- app/views/exports/_index_entry.html.erb | 15 ++++ app/views/exports/index.html.erb | 17 +++++ 3 files changed, 88 insertions(+), 42 deletions(-) create mode 100644 app/views/exports/_index_entry.html.erb create mode 100644 app/views/exports/index.html.erb diff --git a/app/models/concerns/exportable.rb b/app/models/concerns/exportable.rb index 818686d0..3b77d2ed 100644 --- a/app/models/concerns/exportable.rb +++ b/app/models/concerns/exportable.rb @@ -14,46 +14,57 @@ module Exportable extend ActiveSupport::Concern def sanitize_filename(filename) - filename.gsub(/[\x00\/\\:\*\?\"<>\|]/, "_") + filename.gsub(/[\x00\/\\:\*\?\"<>\|]/, "_").strip end - def recursively_deflate_folder(folder, zipfile, zipfile_path, index_html, depth) + def recursively_deflate_folder(folder, zipfile_path, zipfile, depth) zipfile.mkdir(zipfile_path) subdir = folder.contents_children - self.write_zip_entries(subdir, zipfile_path, zipfile, index_html, depth + 1) + self.write_zip_entries(subdir, zipfile_path, zipfile, depth + 1) end def get_path(document_id, current_depth) + # get a relative URL to a document by id, taking into account the current location document = Document.find(document_id) - filename = sanitize_filename(document.title).strip.parameterize + filename = sanitize_filename(document.title).parameterize path_segments = ["#{filename}.html"] while document[:parent_type] != "Project" + # back out from the target document until we hit the project root document = document.parent - path_segments.unshift(sanitize_filename(document[:title]).strip) + path_segments.unshift(sanitize_filename(document[:title])) end - back_out = current_depth > 0 ? Array.new(current_depth, "..").join("/") + "/" : "" - path = back_out + path_segments.join("/") + to_project_root = current_depth > 0 ? Array.new(current_depth, "..").join("/") + "/" : "" + path = to_project_root + path_segments.join("/") return path end - def write_zip_entries(entries, path, zipfile, index_html, depth) + def html_filename(path) + Pathname.new(path) + dir, base = path.split + # use parameterize on basename to produce well-formed URLs + base = Pathname.new(base.to_s.parameterize) + path = dir.join(base) + path = "#{path.to_s}.html" + end + + def write_zip_entries(entries, path, zipfile, depth) entries.each do |child| - name = sanitize_filename(child.title).strip + name = sanitize_filename(child.title) zipfile_path = path == '' ? name : File.join(path, name) - if child.instance_of? DocumentFolder or child.contents_children.length() > 0 - index_html.write("
  • #{child.title}") - index_html.write("
      ") - self.recursively_deflate_folder(child, zipfile, zipfile_path, index_html, depth) - index_html.write("
  • ") + if child.instance_of? DocumentFolder + # create folder AND index entry for folder item + @index_cursor.push({ title: child.title, children: [] }) + old_index_cursor = @index_cursor + @index_cursor = @index_cursor[-1][:children] + self.recursively_deflate_folder(child, zipfile_path, zipfile, depth) + @index_cursor = old_index_cursor + elsif child.contents_children.length() > 0 + # folder, but no index entry, should be created for non-folder item with children + self.recursively_deflate_folder(child, zipfile_path, zipfile, depth) end if not child.instance_of? DocumentFolder - # use parameterize on basename to produce well-formed URLs - zipfile_path = Pathname.new(zipfile_path) - dir, base = zipfile_path.split - base = Pathname.new(base.to_s.parameterize) - zipfile_path = dir.join(base) - zipfile_path = "#{zipfile_path.to_s}.html" - + # create an html file for all non-folder items + zipfile_path = html_filename(zipfile_pth) zipfile.get_output_stream(zipfile_path) { |html_outfile| html_outfile.write('') html_outfile.write("") @@ -115,36 +126,39 @@ def write_zip_entries(entries, path, zipfile, index_html, depth) end html_outfile.write("") } - index_html.write("
  • #{child.title}
  • ") + if ["DocumentFolder", "Project"].include? child.parent_type + # only add direct descendants of project or folder to index + @index_cursor.push({ title: child.title, href: zipfile_path }) + end end end end - - def front_matter - [ - '', - "", - "

    #{self.title}

    ", - "") - index_html.write("") + index_html.write(html) } end end -end \ No newline at end of file +end diff --git a/app/views/exports/_index_entry.html.erb b/app/views/exports/_index_entry.html.erb new file mode 100644 index 00000000..4449c734 --- /dev/null +++ b/app/views/exports/_index_entry.html.erb @@ -0,0 +1,15 @@ +
  • + <% if child[:children] %> +
    + <%= child[:title] %> +
      + <% child[:children].each do |subchild| %> + <%= render partial: 'exports/index_entry', + locals: { child: subchild } %> + <% end %> +
    +
    + <% else %> + <%= child[:title] %> + <% end %> +
  • diff --git a/app/views/exports/index.html.erb b/app/views/exports/index.html.erb new file mode 100644 index 00000000..54a3723e --- /dev/null +++ b/app/views/exports/index.html.erb @@ -0,0 +1,17 @@ + + +

    <%= @index[:title] %>

    + + From 226b30cfca8c0c3238b68442e1ba607dc86a1fb6 Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Wed, 4 Sep 2024 15:50:34 -0400 Subject: [PATCH 04/27] Improve highlight handling (#512) --- app/models/concerns/exportable.rb | 17 ++++++++++------- lib/storyblok_richtext/marks/highlight.rb | 1 + 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/models/concerns/exportable.rb b/app/models/concerns/exportable.rb index 3b77d2ed..cf80fe12 100644 --- a/app/models/concerns/exportable.rb +++ b/app/models/concerns/exportable.rb @@ -39,12 +39,12 @@ def get_path(document_id, current_depth) end def html_filename(path) - Pathname.new(path) + path = Pathname.new(path) dir, base = path.split # use parameterize on basename to produce well-formed URLs base = Pathname.new(base.to_s.parameterize) path = dir.join(base) - path = "#{path.to_s}.html" + "#{path.to_s}.html" end def write_zip_entries(entries, path, zipfile, depth) @@ -64,7 +64,8 @@ def write_zip_entries(entries, path, zipfile, depth) end if not child.instance_of? DocumentFolder # create an html file for all non-folder items - zipfile_path = html_filename(zipfile_pth) + zipfile_path = html_filename(zipfile_path) + zipfile.get_output_stream(zipfile_path) { |html_outfile| html_outfile.write('') html_outfile.write("") @@ -103,13 +104,15 @@ def write_zip_entries(entries, path, zipfile, depth) # add highlights to footer if child.highlight_map.present? - styles = [] + styles = ["li:target { border: 1px solid blue; }"] html_outfile.write("
      ") child.highlight_map.each do |highlight| # list of links on highlight - html_outfile.write("
    1. #{highlight[1].title || highlight[1].excerpt}") + uuid, hl = highlight + html_outfile.write("
    2. ") + html_outfile.write("#{hl.title || hl.excerpt}") html_outfile.write("
        ") - highlight[1].links_to.each do |link| + hl.links_to.each do |link| if link[:document_id].present? html_outfile.write("
      1. #{link[:title]}
      2. ") else @@ -119,7 +122,7 @@ def write_zip_entries(entries, path, zipfile, depth) html_outfile.write("
      ") html_outfile.write("
    3. ") # add style - styles << "a[class*=\"#{highlight[0]}\"] { color: black; background-color: #{highlight[1].color}; }" + styles << "a[class*=\"#{uuid}\"] { color: black; background-color: #{hl.color}; }" end html_outfile.write("
    ") html_outfile.write("") diff --git a/lib/storyblok_richtext/marks/highlight.rb b/lib/storyblok_richtext/marks/highlight.rb index 66b6d0dd..03ed4bae 100644 --- a/lib/storyblok_richtext/marks/highlight.rb +++ b/lib/storyblok_richtext/marks/highlight.rb @@ -13,6 +13,7 @@ def tag tag: 'a', attrs: { class: classname, + id: "highlight-#{highlight_uid}", href: "##{highlight_uid}", } }] From 4e459fb582df0fbf74e94a31bc60bac4201e8b02 Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Thu, 5 Sep 2024 10:31:23 -0400 Subject: [PATCH 05/27] Use templates to render rich text pages (#512) --- app/helpers/export_helper.rb | 19 +++++ app/models/concerns/exportable.rb | 112 ++++++++---------------------- app/views/exports/page.html.erb | 50 +++++++++++++ 3 files changed, 99 insertions(+), 82 deletions(-) create mode 100644 app/helpers/export_helper.rb create mode 100644 app/views/exports/page.html.erb diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb new file mode 100644 index 00000000..6925ae65 --- /dev/null +++ b/app/helpers/export_helper.rb @@ -0,0 +1,19 @@ +module ExportHelper + def self.sanitize_filename(filename) + filename.gsub(/[\x00\/\\:\*\?\"<>\|]/, "_").strip + end + def self.get_path(document_id, current_depth) + # get a relative URL to a document by id, taking into account the current location + document = Document.find(document_id) + filename = self.sanitize_filename(document.title).parameterize + path_segments = ["#{filename}.html"] + while document[:parent_type] != "Project" + # back out from the target document until we hit the project root + document = document.parent + path_segments.unshift(self.sanitize_filename(document[:title])) + end + to_project_root = current_depth > 0 ? Array.new(current_depth, "..").join("/") + "/" : "" + path = to_project_root + path_segments.join("/") + return path + end +end diff --git a/app/models/concerns/exportable.rb b/app/models/concerns/exportable.rb index cf80fe12..09a3fd93 100644 --- a/app/models/concerns/exportable.rb +++ b/app/models/concerns/exportable.rb @@ -13,31 +13,12 @@ module Exportable extend ActiveSupport::Concern - def sanitize_filename(filename) - filename.gsub(/[\x00\/\\:\*\?\"<>\|]/, "_").strip - end - def recursively_deflate_folder(folder, zipfile_path, zipfile, depth) zipfile.mkdir(zipfile_path) subdir = folder.contents_children self.write_zip_entries(subdir, zipfile_path, zipfile, depth + 1) end - def get_path(document_id, current_depth) - # get a relative URL to a document by id, taking into account the current location - document = Document.find(document_id) - filename = sanitize_filename(document.title).parameterize - path_segments = ["#{filename}.html"] - while document[:parent_type] != "Project" - # back out from the target document until we hit the project root - document = document.parent - path_segments.unshift(sanitize_filename(document[:title])) - end - to_project_root = current_depth > 0 ? Array.new(current_depth, "..").join("/") + "/" : "" - path = to_project_root + path_segments.join("/") - return path - end - def html_filename(path) path = Pathname.new(path) dir, base = path.split @@ -49,7 +30,7 @@ def html_filename(path) def write_zip_entries(entries, path, zipfile, depth) entries.each do |child| - name = sanitize_filename(child.title) + name = ExportHelper.sanitize_filename(child.title) zipfile_path = path == '' ? name : File.join(path, name) if child.instance_of? DocumentFolder # create folder AND index entry for folder item @@ -66,68 +47,35 @@ def write_zip_entries(entries, path, zipfile, depth) # create an html file for all non-folder items zipfile_path = html_filename(zipfile_path) - zipfile.get_output_stream(zipfile_path) { |html_outfile| - html_outfile.write('') - html_outfile.write("") - if child.document_kind == "canvas" - # images page - if child[:content] && child[:content]["tileSources"] - for img_url in child.image_urls - html_outfile.write("#{img_url}
    ") - end - else - for img_url in child.image_urls - html_outfile.write("
    ") - end - end - elsif child.document_kind == "text" - # text page - # TODO: handle multicolumn layout - # render text documents from prosemirror/storyblok to html - renderer = Storyblok::Richtext::HtmlRenderer.new - renderer.add_mark(Storyblok::Richtext::Marks::Color) - renderer.add_mark(Storyblok::Richtext::Marks::Em) - renderer.add_mark(Storyblok::Richtext::Marks::FontFamily) - renderer.add_mark(Storyblok::Richtext::Marks::FontSize) - renderer.add_mark(Storyblok::Richtext::Marks::Highlight) - renderer.add_mark(Storyblok::Richtext::Marks::Strikethrough) - renderer.add_mark(Storyblok::Richtext::Marks::TextStyle) - renderer.add_node(Storyblok::Richtext::Nodes::Image) - renderer.add_node(Storyblok::Richtext::Nodes::Paragraph) + if child.document_kind == "text" + # text page + # TODO: handle multicolumn layout + # render text documents from prosemirror/storyblok to html + renderer = Storyblok::Richtext::HtmlRenderer.new + renderer.add_mark(Storyblok::Richtext::Marks::Color) + renderer.add_mark(Storyblok::Richtext::Marks::Em) + renderer.add_mark(Storyblok::Richtext::Marks::FontFamily) + renderer.add_mark(Storyblok::Richtext::Marks::FontSize) + renderer.add_mark(Storyblok::Richtext::Marks::Highlight) + renderer.add_mark(Storyblok::Richtext::Marks::Strikethrough) + renderer.add_mark(Storyblok::Richtext::Marks::TextStyle) + renderer.add_node(Storyblok::Richtext::Nodes::Image) + renderer.add_node(Storyblok::Richtext::Nodes::Paragraph) - html = renderer.render(child[:content]) - html_outfile.write(html) - else - # TODO: determine if there are any of these - html_outfile.write(child.document_kind) - end + content = renderer.render(child[:content]) + end - # add highlights to footer - if child.highlight_map.present? - styles = ["li:target { border: 1px solid blue; }"] - html_outfile.write("
      ") - child.highlight_map.each do |highlight| - # list of links on highlight - uuid, hl = highlight - html_outfile.write("
    1. ") - html_outfile.write("#{hl.title || hl.excerpt}") - html_outfile.write("
        ") - hl.links_to.each do |link| - if link[:document_id].present? - html_outfile.write("
      1. #{link[:title]}
      2. ") - else - html_outfile.write("
      3. #{link[:title]}
      4. ") - end - end - html_outfile.write("
      ") - html_outfile.write("
    2. ") - # add style - styles << "a[class*=\"#{uuid}\"] { color: black; background-color: #{hl.color}; }" - end - html_outfile.write("
    ") - html_outfile.write("") - end - html_outfile.write("") + html = render_template_to_string( + Rails.root.join("app", "views", "exports", "page.html.erb"), + { + highlights: child.highlight_map, + images: child.image_urls, + content: (content || "").html_safe, + depth: depth, + }, + ) + zipfile.get_output_stream(zipfile_path) { |html_outfile| + html_outfile.write(html) } if ["DocumentFolder", "Project"].include? child.parent_type # only add direct descendants of project or folder to index @@ -148,10 +96,10 @@ def export @index = { children: [], title: self.title } @index_cursor = @index[:children] - # t = Tempfile.new("#{sanitize_filename(self.title)}.zip") + # t = Tempfile.new("#{ExportHelper.sanitize_filename(self.title)}.zip") # path = t.path - path = "/Users/ben/Downloads/#{sanitize_filename(self.title)}-#{Time.now.to_s}.zip" + path = "/Users/ben/Downloads/#{ExportHelper.sanitize_filename(self.title)}-#{Time.now.to_s}.zip" Zip::File.open(path, ::Zip::File::CREATE) do |zipfile| self.write_zip_entries(self.contents_children, '', zipfile, depth=0) diff --git a/app/views/exports/page.html.erb b/app/views/exports/page.html.erb new file mode 100644 index 00000000..23d90dd0 --- /dev/null +++ b/app/views/exports/page.html.erb @@ -0,0 +1,50 @@ + + +
    + <% @images.each do |image| %> + <%= image %> +
    + <% end %> + <%= @content %> +
    + <% if @highlights %> +
    +
      + <% @highlights.each do |uuid, hl| %> +
    1. + <%= hl.title || hl.excerpt %> + <% if hl.links_to %> +
        + <% hl.links_to.each do |link| %> +
      1. + <% if link[:document_id].present? %> + + <%= link[:title] %> + + <% else %> + <%= link[:title] %> + <% end %> +
      2. + <% end %> +
      + <% end %> +
    2. + <% end %> +
    +
    + <% end %> + From 403591a4ba37b4974485fe252672466f3955e3a6 Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Fri, 13 Sep 2024 13:03:50 -0400 Subject: [PATCH 06/27] Add table node types to html serialization --- app/models/concerns/exportable.rb | 8 +++++++ lib/storyblok_richtext/nodes/table.rb | 14 +++++++++++ lib/storyblok_richtext/nodes/table_cell.rb | 25 ++++++++++++++++++++ lib/storyblok_richtext/nodes/table_header.rb | 25 ++++++++++++++++++++ lib/storyblok_richtext/nodes/table_row.rb | 14 +++++++++++ 5 files changed, 86 insertions(+) create mode 100644 lib/storyblok_richtext/nodes/table.rb create mode 100644 lib/storyblok_richtext/nodes/table_cell.rb create mode 100644 lib/storyblok_richtext/nodes/table_header.rb create mode 100644 lib/storyblok_richtext/nodes/table_row.rb diff --git a/app/models/concerns/exportable.rb b/app/models/concerns/exportable.rb index 09a3fd93..e26d3fec 100644 --- a/app/models/concerns/exportable.rb +++ b/app/models/concerns/exportable.rb @@ -9,6 +9,10 @@ require 'storyblok_richtext/marks/text_style' require 'storyblok_richtext/nodes/image' require 'storyblok_richtext/nodes/paragraph' +require 'storyblok_richtext/nodes/table' +require 'storyblok_richtext/nodes/table_cell' +require 'storyblok_richtext/nodes/table_header' +require 'storyblok_richtext/nodes/table_row' module Exportable extend ActiveSupport::Concern @@ -61,6 +65,10 @@ def write_zip_entries(entries, path, zipfile, depth) renderer.add_mark(Storyblok::Richtext::Marks::TextStyle) renderer.add_node(Storyblok::Richtext::Nodes::Image) renderer.add_node(Storyblok::Richtext::Nodes::Paragraph) + renderer.add_node(Storyblok::Richtext::Nodes::Table) + renderer.add_node(Storyblok::Richtext::Nodes::TableCell) + renderer.add_node(Storyblok::Richtext::Nodes::TableHeader) + renderer.add_node(Storyblok::Richtext::Nodes::TableRow) content = renderer.render(child[:content]) end diff --git a/lib/storyblok_richtext/nodes/table.rb b/lib/storyblok_richtext/nodes/table.rb new file mode 100644 index 00000000..ddbdcec7 --- /dev/null +++ b/lib/storyblok_richtext/nodes/table.rb @@ -0,0 +1,14 @@ +module Storyblok::Richtext + module Nodes + class Table < Node + + def matching + @node['type'] === 'table' + end + + def tag + ['table', 'tbody'] + end + end + end +end diff --git a/lib/storyblok_richtext/nodes/table_cell.rb b/lib/storyblok_richtext/nodes/table_cell.rb new file mode 100644 index 00000000..0c71d057 --- /dev/null +++ b/lib/storyblok_richtext/nodes/table_cell.rb @@ -0,0 +1,25 @@ +module Storyblok::Richtext + module Nodes + class TableCell < Node + + def matching + @node['type'] === 'table_cell' + end + + def tag + if @node['attrs'] + attrs = {} + attrs['colspan'] = @node['attrs']['colspan'] if @node['attrs']['colspan'] != 1 + attrs['rowspan'] = @node['attrs']['rowspan'] if @node['attrs']['rowspan'] != 1 + attrs['style'] = "background-color: #{@node['attrs']['background']};" if @node['attrs']['background'] + [{ + tag: 'td', + attrs: attrs, + }] + else + 'td' + end + end + end + end +end diff --git a/lib/storyblok_richtext/nodes/table_header.rb b/lib/storyblok_richtext/nodes/table_header.rb new file mode 100644 index 00000000..f381e881 --- /dev/null +++ b/lib/storyblok_richtext/nodes/table_header.rb @@ -0,0 +1,25 @@ +module Storyblok::Richtext + module Nodes + class TableHeader < Node + + def matching + @node['type'] === 'table_header' + end + + def tag + if @node['attrs'] + attrs = {} + attrs['colspan'] = @node['attrs']['colspan'] if @node['attrs']['colspan'] != 1 + attrs['rowspan'] = @node['attrs']['rowspan'] if @node['attrs']['rowspan'] != 1 + attrs['style'] = "background-color: #{@node['attrs']['background']};" if @node['attrs']['background'] + [{ + tag: 'th', + attrs: attrs, + }] + else + 'th' + end + end + end + end +end diff --git a/lib/storyblok_richtext/nodes/table_row.rb b/lib/storyblok_richtext/nodes/table_row.rb new file mode 100644 index 00000000..e868b9db --- /dev/null +++ b/lib/storyblok_richtext/nodes/table_row.rb @@ -0,0 +1,14 @@ +module Storyblok::Richtext + module Nodes + class TableRow < Node + + def matching + @node['type'] === 'table_row' + end + + def tag + 'tr' + end + end + end +end From 9d4c629c96cefe71acab74d80adc3b6f33f96b81 Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Fri, 13 Sep 2024 13:04:36 -0400 Subject: [PATCH 07/27] Improve file naming conventions for export --- app/helpers/export_helper.rb | 2 +- app/models/concerns/exportable.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb index 6925ae65..a40b5ff4 100644 --- a/app/helpers/export_helper.rb +++ b/app/helpers/export_helper.rb @@ -10,7 +10,7 @@ def self.get_path(document_id, current_depth) while document[:parent_type] != "Project" # back out from the target document until we hit the project root document = document.parent - path_segments.unshift(self.sanitize_filename(document[:title])) + path_segments.unshift(self.sanitize_filename(document[:title]).parameterize) end to_project_root = current_depth > 0 ? Array.new(current_depth, "..").join("/") + "/" : "" path = to_project_root + path_segments.join("/") diff --git a/app/models/concerns/exportable.rb b/app/models/concerns/exportable.rb index e26d3fec..66aef898 100644 --- a/app/models/concerns/exportable.rb +++ b/app/models/concerns/exportable.rb @@ -34,7 +34,7 @@ def html_filename(path) def write_zip_entries(entries, path, zipfile, depth) entries.each do |child| - name = ExportHelper.sanitize_filename(child.title) + name = ExportHelper.sanitize_filename(child.title).parameterize zipfile_path = path == '' ? name : File.join(path, name) if child.instance_of? DocumentFolder # create folder AND index entry for folder item @@ -42,7 +42,7 @@ def write_zip_entries(entries, path, zipfile, depth) old_index_cursor = @index_cursor @index_cursor = @index_cursor[-1][:children] self.recursively_deflate_folder(child, zipfile_path, zipfile, depth) - @index_cursor = old_index_cursor + @index_cursor = old_index_cursor elsif child.contents_children.length() > 0 # folder, but no index entry, should be created for non-folder item with children self.recursively_deflate_folder(child, zipfile_path, zipfile, depth) From 9d990e243bef3ea0f2a2d5322f7c6143bb10136f Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Fri, 13 Sep 2024 13:05:11 -0400 Subject: [PATCH 08/27] Fix "ghost highlight" bug in exports --- app/models/concerns/exportable.rb | 1 + app/views/exports/page.html.erb | 40 +++++++++++++++++-------------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/app/models/concerns/exportable.rb b/app/models/concerns/exportable.rb index 66aef898..27083eaa 100644 --- a/app/models/concerns/exportable.rb +++ b/app/models/concerns/exportable.rb @@ -79,6 +79,7 @@ def write_zip_entries(entries, path, zipfile, depth) highlights: child.highlight_map, images: child.image_urls, content: (content || "").html_safe, + document_kind: child.document_kind, depth: depth, }, ) diff --git a/app/views/exports/page.html.erb b/app/views/exports/page.html.erb index 23d90dd0..c60b21f8 100644 --- a/app/views/exports/page.html.erb +++ b/app/views/exports/page.html.erb @@ -25,24 +25,28 @@
      <% @highlights.each do |uuid, hl| %> -
    1. - <%= hl.title || hl.excerpt %> - <% if hl.links_to %> -
        - <% hl.links_to.each do |link| %> -
      1. - <% if link[:document_id].present? %> - - <%= link[:title] %> - - <% else %> - <%= link[:title] %> - <% end %> -
      2. - <% end %> -
      - <% end %> -
    2. + + + <% if @document_kind == "canvas" or @content.include? uuid %> +
    3. + <%= hl.title || hl.excerpt %> + <% if hl.links_to %> +
        + <% hl.links_to.each do |link| %> +
      1. + <% if link[:document_id].present? %> + + <%= link[:title] %> + + <% else %> + <%= link[:title] %> + <% end %> +
      2. + <% end %> +
      + <% end %> +
    4. + <% end %> <% end %>
    From 793ec9281b3a94cdb0c5695c3d28cb826f065593 Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Tue, 17 Sep 2024 11:48:30 -0400 Subject: [PATCH 09/27] Move reused download logic to helper --- app/helpers/download_helper.rb | 18 ++++++++++++++++++ app/models/document.rb | 21 ++------------------- app/models/highlight.rb | 17 ++--------------- 3 files changed, 22 insertions(+), 34 deletions(-) create mode 100644 app/helpers/download_helper.rb diff --git a/app/helpers/download_helper.rb b/app/helpers/download_helper.rb new file mode 100644 index 00000000..3af38ce2 --- /dev/null +++ b/app/helpers/download_helper.rb @@ -0,0 +1,18 @@ +module DownloadHelper + def self.download_to_file(uri) + begin + stream = URI.open(uri, :read_timeout => 10) + return stream if stream.respond_to?(:path) # Already file-like + + # Workaround when open(uri) doesn't return File + Tempfile.new.tap do |file| + file.binmode + IO.copy_stream(stream, file) + stream.close + file.rewind + end + rescue Net::ReadTimeout + return 'failed' + end + end +end \ No newline at end of file diff --git a/app/models/document.rb b/app/models/document.rb index f44a7bb9..4361a8ad 100644 --- a/app/models/document.rb +++ b/app/models/document.rb @@ -148,32 +148,15 @@ def color nil end - def download_to_file(uri) - begin - stream = URI.open(uri, :read_timeout => 10) - return stream if stream.respond_to?(:path) # Already file-like - - # Workaround when open(uri) doesn't return File - Tempfile.new.tap do |file| - file.binmode - IO.copy_stream(stream, file) - stream.close - file.rewind - end - rescue Net::ReadTimeout - return 'failed' - end - end - def add_thumbnail( image_url ) begin # Try with PNG - opened = download_to_file(image_url) + opened = DownloadHelper.download_to_file(image_url) rescue OpenURI::HTTPError # Only JPG is required for IIIF level 1 compliance, # so if we get back a 400 error, use JPG for thumbnail with_jpg = image_url.sub('.png', '.jpg') - opened = download_to_file(with_jpg) + opened = DownloadHelper.download_to_file(with_jpg) end if opened != 'failed' processed = ImageProcessing::MiniMagick.source(opened) diff --git a/app/models/highlight.rb b/app/models/highlight.rb index 9e4b87a8..584e663e 100644 --- a/app/models/highlight.rb +++ b/app/models/highlight.rb @@ -55,19 +55,6 @@ def add_link_from_duplication(linked, original_id, position) end end - def download_to_file(uri) - stream = URI.open(uri) - return stream if stream.respond_to?(:path) # Already file-like - - # Workaround when open(uri) doesn't return File - Tempfile.new.tap do |file| - file.binmode - IO.copy_stream(stream, file) - stream.close - file.rewind - end - end - def set_thumbnail( image_url, thumb_rect ) if !thumb_rect.nil? pad_factor = 0.06 @@ -98,12 +85,12 @@ def set_thumbnail( image_url, thumb_rect ) else begin # Try with PNG - opened = download_to_file(image_url) + opened = DownloadHelper.download_to_file(image_url) rescue OpenURI::HTTPError # Only JPG is required for IIIF level 1 compliance, # so if we get back a 400 error, use JPG for thumbnail with_jpg = image_url.sub('.png', '.jpg') - opened = download_to_file(with_jpg) + opened = DownloadHelper.download_to_file(with_jpg) end io = ImageProcessing::MiniMagick.source(opened) .resize_to_fill(80, 80) From 9e33d7e05da970e7727824b2f1c6ba9357b2cc2a Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Tue, 17 Sep 2024 11:48:54 -0400 Subject: [PATCH 10/27] Add images to export --- app/helpers/export_helper.rb | 32 +++++++---- app/models/concerns/exportable.rb | 91 ++++++++++++++++++++++++++++++- app/views/exports/page.html.erb | 13 +++-- 3 files changed, 118 insertions(+), 18 deletions(-) diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb index a40b5ff4..14ef594a 100644 --- a/app/helpers/export_helper.rb +++ b/app/helpers/export_helper.rb @@ -2,18 +2,28 @@ module ExportHelper def self.sanitize_filename(filename) filename.gsub(/[\x00\/\\:\*\?\"<>\|]/, "_").strip end - def self.get_path(document_id, current_depth) + def self.get_path(link, current_depth) + document_id = link[:document_id] + highlight_uid = link[:highlight_uid] # get a relative URL to a document by id, taking into account the current location - document = Document.find(document_id) - filename = self.sanitize_filename(document.title).parameterize - path_segments = ["#{filename}.html"] - while document[:parent_type] != "Project" - # back out from the target document until we hit the project root - document = document.parent - path_segments.unshift(self.sanitize_filename(document[:title]).parameterize) + begin + document = Document.find(document_id) + filename = self.sanitize_filename(document.title).parameterize + path_segments = ["#{filename}.html"] + while document[:parent_type] != "Project" + # back out from the target document until we hit the project root + document = document.parent + path_segments.unshift(self.sanitize_filename(document[:title]).parameterize) + end + to_project_root = current_depth > 0 ? Array.new(current_depth, "..").join("/") + "/" : "" + path = to_project_root + path_segments.join("/") + if highlight_uid.present? + # append #highlight_uid to url if present in order to target the highlight + path = "#{path}#highlight-#{highlight_uid}" + end + return path + rescue ActiveRecord::RecordNotFound + return "#" end - to_project_root = current_depth > 0 ? Array.new(current_depth, "..").join("/") + "/" : "" - path = to_project_root + path_segments.join("/") - return path end end diff --git a/app/models/concerns/exportable.rb b/app/models/concerns/exportable.rb index 27083eaa..753ee71c 100644 --- a/app/models/concerns/exportable.rb +++ b/app/models/concerns/exportable.rb @@ -32,6 +32,80 @@ def html_filename(path) "#{path.to_s}.html" end + def download_images(doc, zipfile, images_path) + # download all images and store in zip file; + # return a list of hashes of their relative urls and names + images = [] + doc_images = doc.image_urls + doc_images = doc.content["tileSources"] if doc.content["tileSources"].present? + if doc_images.present? and doc_images.length > 0 + begin + # make the images directory + zipfile.mkdir(images_path) + rescue Errno::EEXIST + # ignore error if it already exists + end + end + dupe_filename_count = 0 + doc_images.each { |tileSource| + # try to get url and name for every image + name = nil + if tileSource.is_a?(String) + url = tileSource + else + url = tileSource["url"] + name = tileSource["name"] + end + if url.present? + if name.nil? && !doc.content["iiifTileNames"].nil? && doc.content["iiifTileNames"].length > 0 + # get name from iiifTileNames, if possible + doc.content["iiifTileNames"].each {|tile_name_obj| + if tile_name_obj["url"] == url && tile_name_obj.has_key?("name") + name = tile_name_obj["name"] + end + } + elsif name.nil? + # otherwise just use filename without extension + name = url.rpartition('/').last.rpartition('.').first.sub("%20", " ") + end + begin + # attempt to open url + stream = URI.open(url, :read_timeout => 10) + if stream.content_type.include? "json" + # get max resolution from iiif json + url.sub!("/info.json", "") + url = "#{url}/full/max/0/default.jpg" + end + + # download file and construct local file path + file = DownloadHelper.download_to_file(url) + filename = "#{name}.#{url.rpartition('.').last}" + path = "#{images_path}/#{filename.parameterize}" + begin + # add to zip + zipfile.add(path, file.path) + rescue Zip::EntryExistsError + # handle duplicate filenames by adding numbers to the end + while not zipfile.find_entry(path).nil? + dupe_filename_count += 1 + path_parts = path.rpartition(".") + new_filename = path_parts.first + "-" + dupe_filename_count.to_s + path = "#{new_filename}.#{path_parts.last}" + end + zipfile.add(path, file.path) + end + # have to "commit" so that tempfile is zipped before it is deleted + zipfile.commit + # add to array of hashes + images.push({ url: "images/#{path.rpartition('/').last.parameterize}", name: name }) + rescue Net::ReadTimeout, OpenURI::HTTPError + @errors.push("Error: Failed to download image #{url}, to be stored in #{images_path}") + end + end + } + return images + end + def write_zip_entries(entries, path, zipfile, depth) entries.each do |child| name = ExportHelper.sanitize_filename(child.title).parameterize @@ -48,12 +122,14 @@ def write_zip_entries(entries, path, zipfile, depth) self.recursively_deflate_folder(child, zipfile_path, zipfile, depth) end if not child.instance_of? DocumentFolder + # prepare a path for images in case there are any + images_path = Pathname.new(zipfile_path).split()[0].to_s + "/images" # create an html file for all non-folder items zipfile_path = html_filename(zipfile_path) if child.document_kind == "text" # text page - # TODO: handle multicolumn layout + # TODO: handle multicolumn layout? # render text documents from prosemirror/storyblok to html renderer = Storyblok::Richtext::HtmlRenderer.new renderer.add_mark(Storyblok::Richtext::Marks::Color) @@ -71,16 +147,18 @@ def write_zip_entries(entries, path, zipfile, depth) renderer.add_node(Storyblok::Richtext::Nodes::TableRow) content = renderer.render(child[:content]) + else + images = download_images(child, zipfile, images_path) end - html = render_template_to_string( Rails.root.join("app", "views", "exports", "page.html.erb"), { highlights: child.highlight_map, - images: child.image_urls, + images: (images || []), content: (content || "").html_safe, document_kind: child.document_kind, depth: depth, + title: child.title, }, ) zipfile.get_output_stream(zipfile_path) { |html_outfile| @@ -104,6 +182,7 @@ def render_template_to_string(template_path, data) def export @index = { children: [], title: self.title } @index_cursor = @index[:children] + @errors = [] # t = Tempfile.new("#{ExportHelper.sanitize_filename(self.title)}.zip") # path = t.path @@ -119,6 +198,12 @@ def export zipfile.get_output_stream("index.html") { |index_html| index_html.write(html) } + if @errors.length > 0 + # output to error log if there are any errors + zipfile.get_output_stream("error_log.txt") { |errlog_txt| + errlog_txt.write(@errors.join("\r\n")) + } + end end end end diff --git a/app/views/exports/page.html.erb b/app/views/exports/page.html.erb index c60b21f8..7c077923 100644 --- a/app/views/exports/page.html.erb +++ b/app/views/exports/page.html.erb @@ -6,6 +6,9 @@ a.dm-highlight:target { box-shadow: 0 0px 8px blue, 0 0px 8px blue, 0 0px 8px blue; } + img { + max-width: 100%; + } <% @highlights.each do |uuid, hl| %> a[class*="<%= uuid %>"] { color: black; @@ -14,9 +17,11 @@ <% end %> +

    <%= @title %>

    <% @images.each do |image| %> - <%= image %> +

    <%= image[:name] %>

    +
    <% end %> <%= @content %> @@ -35,11 +40,11 @@ <% hl.links_to.each do |link| %>
  • <% if link[:document_id].present? %> - - <%= link[:title] %> + + <%= link[:title] or link[:excerpt] or link[:document_title] %> <% else %> - <%= link[:title] %> + <%= link[:title] or link[:excerpt] or link[:document_title] %> <% end %>
  • <% end %> From 6a66e937afc79b5b3f30c0ff20af5d986d70900e Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Tue, 17 Sep 2024 14:08:03 -0400 Subject: [PATCH 11/27] Move export code to sidekiq worker --- app/controllers/projects_controller.rb | 15 ++++++- app/models/project.rb | 5 --- .../export_project_worker.rb} | 39 +++++++++++++------ config/routes.rb | 1 + 4 files changed, 42 insertions(+), 18 deletions(-) rename app/{models/concerns/exportable.rb => workers/export_project_worker.rb} (88%) diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 49c55fc2..92e01859 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -1,8 +1,8 @@ class ProjectsController < ApplicationController - before_action :set_project, only: [:show, :update, :destroy, :search, :check_in, :move_many] + before_action :set_project, only: [:show, :update, :destroy, :search, :check_in, :move_many, :create_export] before_action :validate_user_approved, only: [:create] - before_action only: [:update, :destroy] do + before_action only: [:update, :destroy, :create_export] do validate_user_admin(@project) end @@ -71,6 +71,17 @@ def check_in render json: { checked_in_docs: checked_in_doc_ids } end + # POST /projects/1/create_export + def create_export + job_id = ExportProjectWorker.perform_async(@project.id) + @job = { id: job_id } + if @job + render json: @job, status: 202 + else + render status: 500 + end + end + private # Use callbacks to share common setup or constraints between actions. def set_project diff --git a/app/models/project.rb b/app/models/project.rb index 3b3da690..06bd1950 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -11,7 +11,6 @@ class Project < ApplicationRecord before_destroy :destroyer include TreeNode - include Exportable def destroyer self.contents_children.each { |child| @@ -68,8 +67,4 @@ def migrate_to_position! folder.migrate_to_position! } end - - def export_html_zip - self.export - end end diff --git a/app/models/concerns/exportable.rb b/app/workers/export_project_worker.rb similarity index 88% rename from app/models/concerns/exportable.rb rename to app/workers/export_project_worker.rb index 753ee71c..9f385765 100644 --- a/app/models/concerns/exportable.rb +++ b/app/workers/export_project_worker.rb @@ -14,8 +14,16 @@ require 'storyblok_richtext/nodes/table_header' require 'storyblok_richtext/nodes/table_row' -module Exportable - extend ActiveSupport::Concern +class ExportProjectWorker + include Sidekiq::Worker + include Sidekiq::Status::Worker + + def perform(project_id) + @project = Project.find(project_id.to_i) + if @project.present? + self.export + end + end def recursively_deflate_folder(folder, zipfile_path, zipfile, depth) zipfile.mkdir(zipfile_path) @@ -79,8 +87,8 @@ def download_images(doc, zipfile, images_path) # download file and construct local file path file = DownloadHelper.download_to_file(url) - filename = "#{name}.#{url.rpartition('.').last}" - path = "#{images_path}/#{filename.parameterize}" + filename = "#{name.parameterize}.#{url.rpartition('.').last}" + path = "#{images_path}/#{filename}" begin # add to zip zipfile.add(path, file.path) @@ -96,8 +104,12 @@ def download_images(doc, zipfile, images_path) end # have to "commit" so that tempfile is zipped before it is deleted zipfile.commit + # parameterize final filename + filename = path.rpartition("/").last + fn_parts = filename.rpartition(".") + filename = [fn_parts.first.parameterize, *fn_parts[1..-1]].join("") # add to array of hashes - images.push({ url: "images/#{path.rpartition('/').last.parameterize}", name: name }) + images.push({ url: "images/#{filename}", name: name }) rescue Net::ReadTimeout, OpenURI::HTTPError @errors.push("Error: Failed to download image #{url}, to be stored in #{images_path}") end @@ -180,17 +192,18 @@ def render_template_to_string(template_path, data) end def export - @index = { children: [], title: self.title } + @index = { children: [], title: @project.title } @index_cursor = @index[:children] @errors = [] - # t = Tempfile.new("#{ExportHelper.sanitize_filename(self.title)}.zip") - # path = t.path - - path = "/Users/ben/Downloads/#{ExportHelper.sanitize_filename(self.title)}-#{Time.now.to_s}.zip" + # create tempfile + filename = "#{ExportHelper.sanitize_filename(@project.title)}.zip" + tempfile = Tempfile.new(filename) + path = tempfile.path + # write entries to zip Zip::File.open(path, ::Zip::File::CREATE) do |zipfile| - self.write_zip_entries(self.contents_children, '', zipfile, depth=0) + self.write_zip_entries(@project.contents_children, '', zipfile, depth=0) html = render_template_to_string( Rails.root.join("app", "views", "exports", "index.html.erb"), { index: @index }, @@ -205,5 +218,9 @@ def export } end end + + # create blob and upload to storage + blob = ActiveStorage::Blob.create_and_upload!(io: tempfile, filename: filename) + end end diff --git a/config/routes.rb b/config/routes.rb index e6597438..0440d05f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -38,6 +38,7 @@ post '/documents/create_batch' => 'documents#create_batch' post '/jobs' => 'documents#get_jobs_by_id' post '/rails/active_storage/direct_uploads' => 'direct_uploads#create' + post '/projects/:id/create_export' => 'projects#create_export' get '*path', to: "application#fallback_index_html", constraints: ->(request) do !request.xhr? && request.format.html? From 0de02375a70b30ee0276a219528638ce86c280bf Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Thu, 19 Sep 2024 14:23:16 -0400 Subject: [PATCH 12/27] Error handling for failed file download --- app/workers/export_project_worker.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/workers/export_project_worker.rb b/app/workers/export_project_worker.rb index 9f385765..17afa419 100644 --- a/app/workers/export_project_worker.rb +++ b/app/workers/export_project_worker.rb @@ -87,6 +87,10 @@ def download_images(doc, zipfile, images_path) # download file and construct local file path file = DownloadHelper.download_to_file(url) + if file == "failed" + @errors.push("Error: Failed to download image #{url}, to be stored in #{images_path}") + next + end filename = "#{name.parameterize}.#{url.rpartition('.').last}" path = "#{images_path}/#{filename}" begin From d8caa828917797b298be36bf4b50687502729d13 Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Thu, 19 Sep 2024 14:23:58 -0400 Subject: [PATCH 13/27] Prevent retry and keep track of progress --- app/workers/export_project_worker.rb | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/workers/export_project_worker.rb b/app/workers/export_project_worker.rb index 17afa419..fd14e5b8 100644 --- a/app/workers/export_project_worker.rb +++ b/app/workers/export_project_worker.rb @@ -17,6 +17,7 @@ class ExportProjectWorker include Sidekiq::Worker include Sidekiq::Status::Worker + sidekiq_options :retry => 0 def perform(project_id) @project = Project.find(project_id.to_i) @@ -127,14 +128,14 @@ def write_zip_entries(entries, path, zipfile, depth) name = ExportHelper.sanitize_filename(child.title).parameterize zipfile_path = path == '' ? name : File.join(path, name) if child.instance_of? DocumentFolder - # create folder AND index entry for folder item + # folder item: create filesystem folder AND index entry @index_cursor.push({ title: child.title, children: [] }) old_index_cursor = @index_cursor @index_cursor = @index_cursor[-1][:children] self.recursively_deflate_folder(child, zipfile_path, zipfile, depth) @index_cursor = old_index_cursor elsif child.contents_children.length() > 0 - # folder, but no index entry, should be created for non-folder item with children + # non-folder item w/ children: create filesystem folder, but no index entry self.recursively_deflate_folder(child, zipfile_path, zipfile, depth) end if not child.instance_of? DocumentFolder @@ -177,6 +178,8 @@ def write_zip_entries(entries, path, zipfile, depth) title: child.title, }, ) + @current += 1 + at @current zipfile.get_output_stream(zipfile_path) { |html_outfile| html_outfile.write(html) } @@ -199,6 +202,8 @@ def export @index = { children: [], title: @project.title } @index_cursor = @index[:children] @errors = [] + total Document.where(:project_id => @project.id).count + @current = 0 # create tempfile filename = "#{ExportHelper.sanitize_filename(@project.title)}.zip" From 4858177720c86831ac5e1060fc2002f621b6cc7b Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Thu, 19 Sep 2024 14:24:10 -0400 Subject: [PATCH 14/27] Fix zipfile storage bug --- app/workers/export_project_worker.rb | 41 ++++++++++++++++------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/app/workers/export_project_worker.rb b/app/workers/export_project_worker.rb index fd14e5b8..b9629b4b 100644 --- a/app/workers/export_project_worker.rb +++ b/app/workers/export_project_worker.rb @@ -210,26 +210,31 @@ def export tempfile = Tempfile.new(filename) path = tempfile.path - # write entries to zip - Zip::File.open(path, ::Zip::File::CREATE) do |zipfile| - self.write_zip_entries(@project.contents_children, '', zipfile, depth=0) - html = render_template_to_string( - Rails.root.join("app", "views", "exports", "index.html.erb"), - { index: @index }, - ) - zipfile.get_output_stream("index.html") { |index_html| - index_html.write(html) - } - if @errors.length > 0 - # output to error log if there are any errors - zipfile.get_output_stream("error_log.txt") { |errlog_txt| - errlog_txt.write(@errors.join("\r\n")) + begin + Zip::OutputStream.open(tempfile) { |zos| } + # write entries to zip + Zip::File.open(path, ::Zip::File::CREATE) do |zipfile| + self.write_zip_entries(@project.contents_children, '', zipfile, depth=0) + html = render_template_to_string( + Rails.root.join("app", "views", "exports", "index.html.erb"), + { index: @index }, + ) + zipfile.get_output_stream("index.html") { |index_html| + index_html.write(html) } + if @errors.length > 0 + # output to error log if there are any errors + zipfile.get_output_stream("error_log.txt") { |errlog_txt| + errlog_txt.write(@errors.join("\r\n")) + } + end end + zip_data = File.open(tempfile.path) + # attach to project (create blob and upload to storage) + @project.exports.attach(io: zip_data, filename: filename, content_type: 'application/zip') + ensure + tempfile.close + tempfile.unlink end - - # create blob and upload to storage - blob = ActiveStorage::Blob.create_and_upload!(io: tempfile, filename: filename) - end end From 1dee1610269217c333d01084ee99c0d7e30b89b8 Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Thu, 19 Sep 2024 14:44:05 -0400 Subject: [PATCH 15/27] Fix template rendering cache bug --- app/workers/export_project_worker.rb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/workers/export_project_worker.rb b/app/workers/export_project_worker.rb index b9629b4b..e04c737d 100644 --- a/app/workers/export_project_worker.rb +++ b/app/workers/export_project_worker.rb @@ -14,6 +14,14 @@ require 'storyblok_richtext/nodes/table_header' require 'storyblok_richtext/nodes/table_row' +class ViewContext < ActionView::Base + # required to prevent template cache bug; see + # https://github.com/rails/rails/issues/40613#issuecomment-900192395 + def compiled_method_container + self.class + end +end + class ExportProjectWorker include Sidekiq::Worker include Sidekiq::Status::Worker @@ -193,7 +201,7 @@ def write_zip_entries(entries, path, zipfile, depth) def render_template_to_string(template_path, data) lookup_context = ActionView::LookupContext.new(ActionController::Base.view_paths) - context = ActionView::Base.with_empty_template_cache.new(lookup_context, data, nil) + context = ViewContext.new(lookup_context, data, nil) renderer = ActionView::Renderer.new(lookup_context) renderer.render(context, { inline: File.read(template_path) }) end From a12fa87afb1324bcbab9b03441ab950b684904a6 Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Thu, 19 Sep 2024 14:44:27 -0400 Subject: [PATCH 16/27] Use activestorage attachments for exports --- app/models/project.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/project.rb b/app/models/project.rb index 06bd1950..c20ff3c4 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -2,6 +2,7 @@ class Project < ApplicationRecord belongs_to :owner, class_name: 'User', optional: true has_many :documents, as: :parent has_many :document_folders, as: :parent + has_many_attached :exports has_many :user_project_permissions, dependent: :destroy has_many :users, through: :user_project_permissions From 648c91e948b9cb76ff874215904859b02b55b886 Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Thu, 19 Sep 2024 14:50:37 -0400 Subject: [PATCH 17/27] Add route for export monitoring --- app/controllers/projects_controller.rb | 59 +++++++++++++++++++++++++- config/routes.rb | 1 + 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 92e01859..2ab1e9b9 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -1,5 +1,5 @@ class ProjectsController < ApplicationController - before_action :set_project, only: [:show, :update, :destroy, :search, :check_in, :move_many, :create_export] + before_action :set_project, only: [:show, :update, :destroy, :search, :check_in, :move_many, :create_export, :exports] before_action :validate_user_approved, only: [:create] before_action only: [:update, :destroy, :create_export] do @@ -71,6 +71,63 @@ def check_in render json: { checked_in_docs: checked_in_doc_ids } end + # GET /projects/1/exports + def exports + # completed exports + @exports = @project.exports.collect do |exp| + { + :id => exp.id, + :updated_at => exp.created_at, + :status => "Complete", + :url => Rails.application.routes.url_helpers.rails_blob_url(exp), + } + end + # queued exports + queues = Sidekiq::Queue.all + queues.each do |queue| + queue.each do |job| + if job.klass == "ExportProjectWorker" and job.args[0].to_i == @project.id + @exports.push({ + :id => job.jid, + :status => "Queued", + :updated_at => job.created_at, + }) + end + end + end + # in progress exports + worker_set = Sidekiq::WorkSet.new + worker_set.each do |_, _, worker| + if worker.is_a? Hash and worker["payload"]["class"] == "ExportProjectWorker" and worker["payload"]["args"][0].to_i == @project.id + exp = { + :id => worker["payload"]["jid"], + :updated_at => Time.at(worker["payload"]["created_at"]), + :status => "In progress", + } + status = Sidekiq::Status.get_all worker["payload"]["jid"] + if status + exp[:status] = "In progress (#{status['pct_complete']}%)" + exp[:updated_at] = Time.at(status["update_time"].to_f) + end + @exports.push(exp) + end + end + # errored exports + dead_set = Sidekiq::DeadSet.new + dead_set.each do |job| + if job.klass == "ExportProjectWorker" and job.args[0].to_i == @project.id + @exports.push({ + :id => job.jid, + :status => "Failed", + :updated_at => job.created_at, + :error_message => job.item["error_message"], + :error_class => job.item["error_class"], + }) + end + end + render json: @exports.sort_by { |hsh| hsh[:updated_at] }.reverse, status: 200 + end + # POST /projects/1/create_export def create_export job_id = ExportProjectWorker.perform_async(@project.id) diff --git a/config/routes.rb b/config/routes.rb index 0440d05f..7e0f6245 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -39,6 +39,7 @@ post '/jobs' => 'documents#get_jobs_by_id' post '/rails/active_storage/direct_uploads' => 'direct_uploads#create' post '/projects/:id/create_export' => 'projects#create_export' + get '/projects/:id/exports' => 'projects#exports' get '*path', to: "application#fallback_index_html", constraints: ->(request) do !request.xhr? && request.format.html? From 0470974212ba53540fedd20e69a8863ff7130930 Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Thu, 19 Sep 2024 14:51:15 -0400 Subject: [PATCH 18/27] Allow export creation/monitoring/download via frontend --- client/src/ProjectSettingsDialog.js | 138 +++++++++++++++++++++++++++- client/src/modules/project.js | 133 ++++++++++++++++++++++++++- 2 files changed, 266 insertions(+), 5 deletions(-) diff --git a/client/src/ProjectSettingsDialog.js b/client/src/ProjectSettingsDialog.js index 434e7f9a..6b96646d 100644 --- a/client/src/ProjectSettingsDialog.js +++ b/client/src/ProjectSettingsDialog.js @@ -18,7 +18,26 @@ import MenuItem from 'material-ui/MenuItem'; import Toggle from 'material-ui/Toggle'; import Checkbox from 'material-ui/Checkbox'; import { red100, red200, red400, red600, lightBlue100, lightBlue200, grey200, grey600 } from 'material-ui/styles/colors'; -import { hideSettings, updateProject, setNewPermissionUser, setNewPermissionLevel, createNewPermission, deletePermission, updatePermission, toggleDeleteConfirmation, deleteProject, READ_PERMISSION, WRITE_PERMISSION, ADMIN_PERMISSION } from './modules/project'; +import IconButton from 'material-ui/IconButton'; +import ErrorIcon from 'material-ui/svg-icons/alert/error'; +import CheckIcon from 'material-ui/svg-icons/navigation/check'; +import RefreshIcon from 'material-ui/svg-icons/navigation/refresh'; +import { + ADMIN_PERMISSION, + READ_PERMISSION, + WRITE_PERMISSION, + createExport, + createNewPermission, + deletePermission, + deleteProject, + hideSettings, + loadExports, + setNewPermissionLevel, + setNewPermissionUser, + toggleDeleteConfirmation, + updatePermission, + updateProject, +} from "./modules/project"; class ProjectSettingsDialog extends Component { @@ -122,11 +141,13 @@ class ProjectSettingsDialog extends Component { return ( {this.scheduleProjectTitleUpdate(newValue);}} />
    {this.scheduleProjectDescriptionUpdate(newValue);}} @@ -144,6 +165,101 @@ class ProjectSettingsDialog extends Component { ) } + renderExportsTab() { + const { exports, createExport } = this.props; + let exportsTable; + if (exports && exports.length) { + const exportRows = exports.map((exp) => { + let updatedAt = exp.updated_at; + if (typeof updatedAt !== 'string') updatedAt *= 1000; + updatedAt = new Date(updatedAt); + let exportLink = "-"; + if (exp.url) { + exportLink = Download; + } + let status = exp.status; + if (exp.error_class) { + status = <> + {exp.status} + {}} + disableFocusRipple + disableTouchRipple + > + + + + } + return ( + + {updatedAt.toLocaleString()} + + {status} + + {exportLink} + + ) + }); + exportsTable = ( + + + + Date + + Status + { this.props.loadExports(this.props.id) }} + disabled={this.props.exportsLoading} + > + + + + Actions + + + + {exportRows} + +
    + ) + } + let createStatus; + if (this.props.exportJobId) { + createStatus = ( + <> + + Initiated successfully + + ) + } + return ( + + {exportsTable} +

    + Initiate an export for archival purposes. This will create a .zip file with simple + HTML representations of this project's contents, preserving all text, images, and + annotations. It may take some time to produce; check back here for its status. +

    +
    + {this.props.createExport()}} + backgroundColor={lightBlue100} + hoverColor={lightBlue200} + disabled={!!this.props.exportsLoading || !!this.props.exportJobId} + /> + {createStatus} +
    +
    + ); + } + renderDeleteTab() { const { id, deleteConfirmed, toggleDeleteConfirmation } = this.props; @@ -185,11 +301,20 @@ class ProjectSettingsDialog extends Component { onClick={hideSettings} /> ]} - contentStyle={{ width: '90%', maxWidth: '1000px' }} + contentStyle={{ + width: '90%', + maxWidth: '1000px', + position: 'absolute', + left: '50%', + top: '65px', + transform: 'translate(-50%, 0)', + transition: 'top 0.5s ease', + }} > { this.renderProjectTab() } { this.renderCollaboratorsTab() } + { this.renderExportsTab() } { this.renderDeleteTab() } @@ -227,7 +352,10 @@ const mapStateToProps = state => ({ newPermissionLevel: state.project.newPermissionLevel, newPermissionError: state.project.newPermissionError, newPermissionLoading: state.project.newPermissionLoading, - deleteConfirmed: state.project.deleteConfirmed + deleteConfirmed: state.project.deleteConfirmed, + exports: state.project.exports, + exportsLoading: state.project.exportsLoading, + exportJobId: state.project.exportJobId, }); const mapDispatchToProps = dispatch => bindActionCreators({ @@ -239,7 +367,9 @@ const mapDispatchToProps = dispatch => bindActionCreators({ deletePermission, updatePermission, toggleDeleteConfirmation, - deleteProject + deleteProject, + createExport, + loadExports, }, dispatch); export default connect( diff --git a/client/src/modules/project.js b/client/src/modules/project.js index 5f63a818..010b2236 100644 --- a/client/src/modules/project.js +++ b/client/src/modules/project.js @@ -45,6 +45,12 @@ export const ADD_FOLDER_DATA = 'project/ADD_FOLDER_DATA'; export const SHOW_CLOSE_DIALOG = 'project/SHOW_CLOSE_DIALOG'; export const HIDE_CLOSE_DIALOG = 'project/HIDE_CLOSE_DIALOG'; export const IMAGE_UPLOAD_DOC_CREATED = 'project/IMAGE_UPLOAD_DOC_CREATED'; +export const GET_EXPORTS_LOADING = 'project/GET_EXPORTS_LOADING'; +export const GET_EXPORTS_SUCCESS = 'project/GET_EXPORTS_SUCCESS'; +export const GET_EXPORTS_ERRORED = 'project/GET_EXPORTS_ERRORED'; +export const CREATE_EXPORT_LOADING = 'project/CREATE_EXPORT_LOADING'; +export const CREATE_EXPORT_SUCCESS = 'project/CREATE_EXPORT_SUCCESS'; +export const CREATE_EXPORT_ERRORED = 'project/CREATE_EXPORT_ERRORED'; const sidebarOpenWidth = 490 @@ -73,6 +79,10 @@ const initialState = { folderData: [], closeDialogShown: false, uploadError: null, + exports: {}, + exportsError: null, + exportsLoading: false, + exportJobId: null, }; export default function(state = initialState, action) { @@ -315,6 +325,51 @@ export default function(state = initialState, action) { ...state, closeDialogShown: false, } + + case GET_EXPORTS_LOADING: + return { + ...state, + exportsError: null, + exportsLoading: true, + } + + case GET_EXPORTS_SUCCESS: + return { + ...state, + exports: action.exports, + exportsError: null, + exportsLoading: false, + } + + case GET_EXPORTS_ERRORED: + return { + ...state, + exportsError: action.error, + exportsLoading: false, + } + + case CREATE_EXPORT_LOADING: + return { + ...state, + exportsError: null, + exportsLoading: true, + } + + case CREATE_EXPORT_SUCCESS: + return { + ...state, + exportJobId: action.jobId, + exportsError: null, + exportsLoading: false, + } + + case CREATE_EXPORT_ERRORED: + return { + ...state, + exportsError: action.error, + exportsLoading: false, + } + default: return state; } @@ -366,7 +421,8 @@ export function loadProject(projectId, title) { userProjectPermissions: project['user_project_permissions'], public: project.public, currentUserPermissions: project['current_user_permissions'] - }) + }); + dispatch(loadExports(project.id)); } }) .catch(() => dispatch({ @@ -847,4 +903,79 @@ export function hideCloseDialog() { type: HIDE_CLOSE_DIALOG, }) } +} + +export function loadExports(projectId) { + return function(dispatch) { + dispatch({ + type: GET_EXPORTS_LOADING + }); + + fetch(`projects/${projectId}/exports`, { + headers: { + 'access-token': localStorage.getItem('access-token'), + 'token-type': localStorage.getItem('token-type'), + 'client': localStorage.getItem('client'), + 'expiry': localStorage.getItem('expiry'), + 'uid': localStorage.getItem('uid') + } + }) + .then(response => { + if (!response.ok) { + throw Error(response.statusText); + } + return response; + }) + .then(response => response.json()) + .then(data => { + dispatch({ + type: GET_EXPORTS_SUCCESS, + exports: data, + }); + }) + .catch(() => dispatch({ + type: GET_EXPORTS_ERRORED + })); + }; +} + +export function createExport() { + return function(dispatch, getState) { + const { id } = getState().project; + dispatch({ + type: CREATE_EXPORT_LOADING + }); + + fetch(`/projects/${id}/create_export`, { + headers: { + 'access-token': localStorage.getItem('access-token'), + 'token-type': localStorage.getItem('token-type'), + 'client': localStorage.getItem('client'), + 'expiry': localStorage.getItem('expiry'), + 'uid': localStorage.getItem('uid'), + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + method: 'POST', + }) + .then(response => { + if (!response.ok) { + throw Error(response.statusText); + } + return response.json(); + }) + .then((job) => { + setTimeout(() => { + dispatch(loadExports(id)); + }, 1000); + dispatch({ + type: CREATE_EXPORT_SUCCESS, + jobId: job.id, + }); + }) + .catch((err) => dispatch({ + type: CREATE_EXPORT_ERRORED, + error: err, + })); + } } \ No newline at end of file From 041551869344ff0bd99b4b54fec42e2fc3ef887b Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Thu, 19 Sep 2024 14:56:33 -0400 Subject: [PATCH 19/27] Minor reorganization --- app/workers/export_project_worker.rb | 220 ++++++++++++++------------- 1 file changed, 112 insertions(+), 108 deletions(-) diff --git a/app/workers/export_project_worker.rb b/app/workers/export_project_worker.rb index e04c737d..09c67e74 100644 --- a/app/workers/export_project_worker.rb +++ b/app/workers/export_project_worker.rb @@ -34,7 +34,118 @@ def perform(project_id) end end + def export + # main export function: set up, create zipfile, write entries, attach zipfile + @index = { children: [], title: @project.title } + @index_cursor = @index[:children] + @errors = [] + total Document.where(:project_id => @project.id).count + @current = 0 + + # create tempfile + filename = "#{ExportHelper.sanitize_filename(@project.title)}.zip" + tempfile = Tempfile.new(filename) + path = tempfile.path + + begin + Zip::OutputStream.open(tempfile) { |zos| } + # write entries to zip + Zip::File.open(path, ::Zip::File::CREATE) do |zipfile| + self.write_zip_entries(@project.contents_children, '', zipfile, depth=0) + html = render_template_to_string( + Rails.root.join("app", "views", "exports", "index.html.erb"), + { index: @index }, + ) + zipfile.get_output_stream("index.html") { |index_html| + index_html.write(html) + } + if @errors.length > 0 + # output to error log if there are any errors + zipfile.get_output_stream("error_log.txt") { |errlog_txt| + errlog_txt.write(@errors.join("\r\n")) + } + end + end + zip_data = File.open(tempfile.path) + # attach to project (create blob and upload to storage) + @project.exports.attach(io: zip_data, filename: filename, content_type: 'application/zip') + ensure + tempfile.close + tempfile.unlink + end + end + + def write_zip_entries(entries, path, zipfile, depth) + # handle each entry in @project.contents_children + entries.each do |child| + name = ExportHelper.sanitize_filename(child.title).parameterize + zipfile_path = path == '' ? name : File.join(path, name) + if child.instance_of? DocumentFolder + # folder item: create filesystem folder AND index entry + @index_cursor.push({ title: child.title, children: [] }) + old_index_cursor = @index_cursor + @index_cursor = @index_cursor[-1][:children] + self.recursively_deflate_folder(child, zipfile_path, zipfile, depth) + @index_cursor = old_index_cursor + elsif child.contents_children.length() > 0 + # non-folder item w/ children: create filesystem folder, but no index entry + self.recursively_deflate_folder(child, zipfile_path, zipfile, depth) + end + if not child.instance_of? DocumentFolder + # prepare a path for images in case there are any + images_path = Pathname.new(zipfile_path).split()[0].to_s + "/images" + # create an html file for all non-folder items + zipfile_path = html_filename(zipfile_path) + + if child.document_kind == "text" + # text page + # TODO: handle multicolumn layout? + # render text documents from prosemirror/storyblok to html + renderer = Storyblok::Richtext::HtmlRenderer.new + renderer.add_mark(Storyblok::Richtext::Marks::Color) + renderer.add_mark(Storyblok::Richtext::Marks::Em) + renderer.add_mark(Storyblok::Richtext::Marks::FontFamily) + renderer.add_mark(Storyblok::Richtext::Marks::FontSize) + renderer.add_mark(Storyblok::Richtext::Marks::Highlight) + renderer.add_mark(Storyblok::Richtext::Marks::Strikethrough) + renderer.add_mark(Storyblok::Richtext::Marks::TextStyle) + renderer.add_node(Storyblok::Richtext::Nodes::Image) + renderer.add_node(Storyblok::Richtext::Nodes::Paragraph) + renderer.add_node(Storyblok::Richtext::Nodes::Table) + renderer.add_node(Storyblok::Richtext::Nodes::TableCell) + renderer.add_node(Storyblok::Richtext::Nodes::TableHeader) + renderer.add_node(Storyblok::Richtext::Nodes::TableRow) + + content = renderer.render(child[:content]) + else + images = download_images(child, zipfile, images_path) + end + html = render_template_to_string( + Rails.root.join("app", "views", "exports", "page.html.erb"), + { + highlights: child.highlight_map, + images: (images || []), + content: (content || "").html_safe, + document_kind: child.document_kind, + depth: depth, + title: child.title, + }, + ) + @current += 1 + at @current + zipfile.get_output_stream(zipfile_path) { |html_outfile| + html_outfile.write(html) + } + if ["DocumentFolder", "Project"].include? child.parent_type + # only add direct descendants of project or folder to index + @index_cursor.push({ title: child.title, href: zipfile_path }) + end + end + end + end + def recursively_deflate_folder(folder, zipfile_path, zipfile, depth) + # from rubyzip docs https://github.com/rubyzip/rubyzip zipfile.mkdir(zipfile_path) subdir = folder.contents_children self.write_zip_entries(subdir, zipfile_path, zipfile, depth + 1) @@ -131,118 +242,11 @@ def download_images(doc, zipfile, images_path) return images end - def write_zip_entries(entries, path, zipfile, depth) - entries.each do |child| - name = ExportHelper.sanitize_filename(child.title).parameterize - zipfile_path = path == '' ? name : File.join(path, name) - if child.instance_of? DocumentFolder - # folder item: create filesystem folder AND index entry - @index_cursor.push({ title: child.title, children: [] }) - old_index_cursor = @index_cursor - @index_cursor = @index_cursor[-1][:children] - self.recursively_deflate_folder(child, zipfile_path, zipfile, depth) - @index_cursor = old_index_cursor - elsif child.contents_children.length() > 0 - # non-folder item w/ children: create filesystem folder, but no index entry - self.recursively_deflate_folder(child, zipfile_path, zipfile, depth) - end - if not child.instance_of? DocumentFolder - # prepare a path for images in case there are any - images_path = Pathname.new(zipfile_path).split()[0].to_s + "/images" - # create an html file for all non-folder items - zipfile_path = html_filename(zipfile_path) - - if child.document_kind == "text" - # text page - # TODO: handle multicolumn layout? - # render text documents from prosemirror/storyblok to html - renderer = Storyblok::Richtext::HtmlRenderer.new - renderer.add_mark(Storyblok::Richtext::Marks::Color) - renderer.add_mark(Storyblok::Richtext::Marks::Em) - renderer.add_mark(Storyblok::Richtext::Marks::FontFamily) - renderer.add_mark(Storyblok::Richtext::Marks::FontSize) - renderer.add_mark(Storyblok::Richtext::Marks::Highlight) - renderer.add_mark(Storyblok::Richtext::Marks::Strikethrough) - renderer.add_mark(Storyblok::Richtext::Marks::TextStyle) - renderer.add_node(Storyblok::Richtext::Nodes::Image) - renderer.add_node(Storyblok::Richtext::Nodes::Paragraph) - renderer.add_node(Storyblok::Richtext::Nodes::Table) - renderer.add_node(Storyblok::Richtext::Nodes::TableCell) - renderer.add_node(Storyblok::Richtext::Nodes::TableHeader) - renderer.add_node(Storyblok::Richtext::Nodes::TableRow) - - content = renderer.render(child[:content]) - else - images = download_images(child, zipfile, images_path) - end - html = render_template_to_string( - Rails.root.join("app", "views", "exports", "page.html.erb"), - { - highlights: child.highlight_map, - images: (images || []), - content: (content || "").html_safe, - document_kind: child.document_kind, - depth: depth, - title: child.title, - }, - ) - @current += 1 - at @current - zipfile.get_output_stream(zipfile_path) { |html_outfile| - html_outfile.write(html) - } - if ["DocumentFolder", "Project"].include? child.parent_type - # only add direct descendants of project or folder to index - @index_cursor.push({ title: child.title, href: zipfile_path }) - end - end - end - end - def render_template_to_string(template_path, data) + # helper function to render a template to string outside of controller lookup_context = ActionView::LookupContext.new(ActionController::Base.view_paths) context = ViewContext.new(lookup_context, data, nil) renderer = ActionView::Renderer.new(lookup_context) renderer.render(context, { inline: File.read(template_path) }) end - - def export - @index = { children: [], title: @project.title } - @index_cursor = @index[:children] - @errors = [] - total Document.where(:project_id => @project.id).count - @current = 0 - - # create tempfile - filename = "#{ExportHelper.sanitize_filename(@project.title)}.zip" - tempfile = Tempfile.new(filename) - path = tempfile.path - - begin - Zip::OutputStream.open(tempfile) { |zos| } - # write entries to zip - Zip::File.open(path, ::Zip::File::CREATE) do |zipfile| - self.write_zip_entries(@project.contents_children, '', zipfile, depth=0) - html = render_template_to_string( - Rails.root.join("app", "views", "exports", "index.html.erb"), - { index: @index }, - ) - zipfile.get_output_stream("index.html") { |index_html| - index_html.write(html) - } - if @errors.length > 0 - # output to error log if there are any errors - zipfile.get_output_stream("error_log.txt") { |errlog_txt| - errlog_txt.write(@errors.join("\r\n")) - } - end - end - zip_data = File.open(tempfile.path) - # attach to project (create blob and upload to storage) - @project.exports.attach(io: zip_data, filename: filename, content_type: 'application/zip') - ensure - tempfile.close - tempfile.unlink - end - end end From 48f93e0d6d3470617dc14407e1a93e300767b70a Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Tue, 24 Sep 2024 16:17:51 -0400 Subject: [PATCH 20/27] Bump listen gem to prevent memory error on MacOS --- Gemfile | 2 +- Gemfile.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Gemfile b/Gemfile index db3520b8..6e7a47c8 100644 --- a/Gemfile +++ b/Gemfile @@ -39,7 +39,7 @@ group :development, :test do end group :development do - gem 'listen', '>= 3.0.5', '< 3.2' + gem 'listen', '>= 3.3.0', '< 4.0' # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring gem 'spring' gem 'spring-watcher-listen', '~> 2.0.0' diff --git a/Gemfile.lock b/Gemfile.lock index 63c4c034..69b5ffd8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -140,9 +140,9 @@ GEM ruby-vips (>= 2.0.17, < 3) jmespath (1.6.2) jsonapi-renderer (0.2.2) - listen (3.0.8) - rb-fsevent (~> 0.9, >= 0.9.4) - rb-inotify (~> 0.9, >= 0.9.7) + listen (3.9.0) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) logger (1.6.0) loofah (2.22.0) crass (~> 1.0.2) @@ -304,7 +304,7 @@ DEPENDENCIES figaro foreman image_processing (~> 1.12) - listen (>= 3.0.5, < 3.2) + listen (>= 3.3.0, < 4.0) mutex_m (~> 0.2.0) open-uri pg (>= 0.18, < 2.0) From e976c0b445704207af4a3d928afa4c884aeb5451 Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Wed, 25 Sep 2024 10:55:46 -0400 Subject: [PATCH 21/27] Add image annotations to export (#514) --- app/helpers/export_helper.rb | 75 ++++++++++++++++++++++++++++ app/views/exports/page.html.erb | 24 ++++++++- app/workers/export_project_worker.rb | 14 +++++- 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb index 14ef594a..884358d5 100644 --- a/app/helpers/export_helper.rb +++ b/app/helpers/export_helper.rb @@ -2,6 +2,7 @@ module ExportHelper def self.sanitize_filename(filename) filename.gsub(/[\x00\/\\:\*\?\"<>\|]/, "_").strip end + def self.get_path(link, current_depth) document_id = link[:document_id] highlight_uid = link[:highlight_uid] @@ -26,4 +27,78 @@ def self.get_path(link, current_depth) return "#" end end + + def self.get_svg_styles(obj) + # convert fabric object style properties to css + styles = [ + "stroke: #{obj['stroke']};", + "fill: #{obj['fill'] || 'transparent'};", + "stroke-width: 3px;", + "stroke-linecap: #{obj['strokeLineCap'] || 'butt'};", + "stroke-dashoffset: #{obj['strokeDashOffset'] || '0'};", + "stroke-linejoin: #{obj['strokeLineJoin'] || 'miter'};", + "stroke-miterlimit: #{obj['strokeMiterLimit'] || '4'};", + "opacity: #{obj['opacity'] || '1'};", + "visibility: #{obj['visible'] ? 'visible' : 'hidden'};", + ] + styles.push("stroke-dasharray: #{obj['strokeDashArray']};") if obj['strokeDashArray'] + styles.join(" ") + end + + def self.get_svg_path(paths) + # convert fabric object path property to svg path + path = '' + paths.each_with_index do |ls, i| + path += " " unless i == 0 + if ls[0] == "C" + # C x1 y1, x2 y2, x y + path += "#{ls[0]} #{ls[1]} #{ls[2]}, #{ls[3]} #{ls[4]}, #{ls[5]} #{ls[6]}" + elsif ls[0] == "S" or ls[0] == "Q" + # S x2 y2, x y || Q x1 y1, x y + path += "#{ls[0]} #{ls[1]} #{ls[2]}, #{ls[3]} #{ls[4]}" + else + # M x y || L x y || T x y, etc + path += ls.join(" ") + end + end + path + end + + def self.fabric_to_svg(highlights) + # convert image annotation highlights (fabric objects) to svgs + svgs = [] + highlights.each do |uid, hl| + svg_hash = JSON.parse(hl[:target]) + elm = "#{svg_hash['type']}" + if svg_hash["path"] + # path + elm += " d=\"#{self.get_svg_path(svg_hash['path'])}\"" + elsif svg_hash["points"] + # polyline + elm += ' points="' + elm += svg_hash["points"].map { |pt| "#{pt['x']},#{pt['y']}" }.join(" ") + elm += '"' + elsif svg_hash["type"] == "circle" + # circle + elm += " r=\"#{svg_hash['radius']}\"" + cx = svg_hash["left"] + cy = svg_hash["top"] + if svg_hash["originX"] == "left" + cx += svg_hash["radius"] + cy += svg_hash["radius"] + end + elm += " cx=\"#{cx}\" cy=\"#{cy}\"" + elsif svg_hash["type"] == "rect" + # rect + elm += " x=\"#{svg_hash['left']}\" y=\"#{svg_hash['top']}\"" + elm += " width=\"#{svg_hash['width']}\" height=\"#{svg_hash['height']}\"" + end + # common styles + elm += " style=\"#{self.get_svg_styles(svg_hash)}\"" + svg_elm = "<#{elm} vector-effect=\"non-scaling-stroke\" />" + # add link to highlight in footer + svgs.push("#{svg_elm}") + end + return svgs + end end diff --git a/app/views/exports/page.html.erb b/app/views/exports/page.html.erb index 7c077923..147a1757 100644 --- a/app/views/exports/page.html.erb +++ b/app/views/exports/page.html.erb @@ -9,6 +9,21 @@ img { max-width: 100%; } + svg a { + cursor: pointer; + } + svg a:target { + filter: drop-shadow(1px 1px 2px blue); + } + .img-anno-container { + position: relative; + width: 100%; + } + .img-anno-container svg { + position: absolute; + top: 0; + left: 0; + } <% @highlights.each do |uuid, hl| %> a[class*="<%= uuid %>"] { color: black; @@ -21,7 +36,14 @@
    <% @images.each do |image| %>

    <%= image[:name] %>

    - +
    + + + <% @svg_highlights.each do |svg_highlight| %> + <%= svg_highlight.html_safe %> + <% end %> + +

    <% end %> <%= @content %> diff --git a/app/workers/export_project_worker.rb b/app/workers/export_project_worker.rb index 09c67e74..15fe283c 100644 --- a/app/workers/export_project_worker.rb +++ b/app/workers/export_project_worker.rb @@ -119,12 +119,14 @@ def write_zip_entries(entries, path, zipfile, depth) content = renderer.render(child[:content]) else images = download_images(child, zipfile, images_path) + svg_highlights = ExportHelper.fabric_to_svg(child.highlight_map) end html = render_template_to_string( Rails.root.join("app", "views", "exports", "page.html.erb"), { highlights: child.highlight_map, images: (images || []), + svg_highlights: (svg_highlights || []), content: (content || "").html_safe, document_kind: child.document_kind, depth: depth, @@ -205,12 +207,16 @@ def download_images(doc, zipfile, images_path) url = "#{url}/full/max/0/default.jpg" end - # download file and construct local file path + # download file file = DownloadHelper.download_to_file(url) if file == "failed" @errors.push("Error: Failed to download image #{url}, to be stored in #{images_path}") next end + # determine dimensions and get scale factor for svg display + image_dims = MiniMagick::Image.open(file.path) + scale_factor = 2000.0 / image_dims[:width] + # construct local file path filename = "#{name.parameterize}.#{url.rpartition('.').last}" path = "#{images_path}/#{filename}" begin @@ -233,7 +239,11 @@ def download_images(doc, zipfile, images_path) fn_parts = filename.rpartition(".") filename = [fn_parts.first.parameterize, *fn_parts[1..-1]].join("") # add to array of hashes - images.push({ url: "images/#{filename}", name: name }) + images.push({ + url: "images/#{filename}", + name: name, + height: image_dims[:height] * scale_factor, + }) rescue Net::ReadTimeout, OpenURI::HTTPError @errors.push("Error: Failed to download image #{url}, to be stored in #{images_path}") end From ab04e1bc0886c979c4a32ebd4029727fcb9da4c3 Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Wed, 25 Sep 2024 14:40:51 -0400 Subject: [PATCH 22/27] Add nil check to highlight.links_to --- app/models/highlight.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/highlight.rb b/app/models/highlight.rb index 584e663e..59710937 100644 --- a/app/models/highlight.rb +++ b/app/models/highlight.rb @@ -7,7 +7,7 @@ class Highlight < Linkable def links_to all_links = self.highlights_links.sort_by{ |hll| hll.position }.map{ |hll| Link.where(:id => hll.link_id).first } - result = all_links.map { |link| self.to_link_obj(link) }.compact + result = all_links.map { |link| self.to_link_obj(link) unless link.nil? }.compact result.each {|r| if r[:highlight_id] hl = Highlight.where(:id => r[:highlight_id]).first From e2ffcd9946f8231845682ba17bcda5154c5cb66b Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Thu, 26 Sep 2024 13:11:41 -0400 Subject: [PATCH 23/27] Improve export UX slightly --- client/src/modules/project.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/modules/project.js b/client/src/modules/project.js index 010b2236..d71cc521 100644 --- a/client/src/modules/project.js +++ b/client/src/modules/project.js @@ -339,6 +339,7 @@ export default function(state = initialState, action) { exports: action.exports, exportsError: null, exportsLoading: false, + exportJobId: null, } case GET_EXPORTS_ERRORED: @@ -967,7 +968,7 @@ export function createExport() { .then((job) => { setTimeout(() => { dispatch(loadExports(id)); - }, 1000); + }, 3000); dispatch({ type: CREATE_EXPORT_SUCCESS, jobId: job.id, From df6a03404a6a4a2ced7abcb6750ef8c4653f0ade Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Thu, 26 Sep 2024 13:12:54 -0400 Subject: [PATCH 24/27] Retain link title formatting from existing UI in export (#512) --- app/helpers/export_helper.rb | 9 +++++++++ app/views/exports/page.html.erb | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb index 884358d5..5fb175ca 100644 --- a/app/helpers/export_helper.rb +++ b/app/helpers/export_helper.rb @@ -101,4 +101,13 @@ def self.fabric_to_svg(highlights) end return svgs end + + def self.get_link_label(link) + label = link[:document_title] + if link[:excerpt] and link[:excerpt].length > 0 + label = "#{link[:title] ? link[:title] : link[:excerpt]}" + label += " in #{link[:document_title]}" + end + label + end end diff --git a/app/views/exports/page.html.erb b/app/views/exports/page.html.erb index 147a1757..29edbe2f 100644 --- a/app/views/exports/page.html.erb +++ b/app/views/exports/page.html.erb @@ -63,10 +63,10 @@
  • <% if link[:document_id].present? %> - <%= link[:title] or link[:excerpt] or link[:document_title] %> + <%= ExportHelper.get_link_label(link).html_safe %> <% else %> - <%= link[:title] or link[:excerpt] or link[:document_title] %> + <%= ExportHelper.get_link_label(link).html_safe %> <% end %>
  • <% end %> From af6d7a99ec4a3b3e190e53e3d1ec4de9767fc491 Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Thu, 26 Sep 2024 13:13:15 -0400 Subject: [PATCH 25/27] Order highlights correctly according to reading order (#512, #514) --- app/helpers/export_helper.rb | 22 +++++++++++++++++++++- app/workers/export_project_worker.rb | 5 ++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb index 5fb175ca..441c04fa 100644 --- a/app/helpers/export_helper.rb +++ b/app/helpers/export_helper.rb @@ -67,7 +67,7 @@ def self.get_svg_path(paths) def self.fabric_to_svg(highlights) # convert image annotation highlights (fabric objects) to svgs svgs = [] - highlights.each do |uid, hl| + self.order_highlights(highlights, "canvas", nil).each do |uid, hl| svg_hash = JSON.parse(hl[:target]) elm = "#{svg_hash['type']}" if svg_hash["path"] @@ -102,6 +102,26 @@ def self.fabric_to_svg(highlights) return svgs end + def self.order_highlights(highlights, document_kind, content_html) + if !content_html.present? and document_kind == "text" + highlights + elsif content_html.present? + # text type document: order by position in text + highlights.sort_by { |uid, hl| content_html.index(uid) || Float::INFINITY } + elsif document_kind == "canvas" + # canvas type document: order highlights by position on page (top to bottom, LTR) + highlights.sort_by { |uid, hl| + drawing = JSON.parse(hl[:target]) + # divide page vertically into 50px blocks; consider all within 50px range to have equal y + y = (drawing["top"] / 50).floor() + # then sort by x, unmodified + x = drawing["left"] + puts "#{hl[:title]}: #{([y, x]).to_s}" + [y, x] + } + end + end + def self.get_link_label(link) label = link[:document_title] if link[:excerpt] and link[:excerpt].length > 0 diff --git a/app/workers/export_project_worker.rb b/app/workers/export_project_worker.rb index 15fe283c..ba339f11 100644 --- a/app/workers/export_project_worker.rb +++ b/app/workers/export_project_worker.rb @@ -120,11 +120,14 @@ def write_zip_entries(entries, path, zipfile, depth) else images = download_images(child, zipfile, images_path) svg_highlights = ExportHelper.fabric_to_svg(child.highlight_map) + content = nil end html = render_template_to_string( Rails.root.join("app", "views", "exports", "page.html.erb"), { - highlights: child.highlight_map, + highlights: ExportHelper.order_highlights( + child.highlight_map, child.document_kind, content, + ), images: (images || []), svg_highlights: (svg_highlights || []), content: (content || "").html_safe, From 67563077ec7df5f7c7bd4e3037f2786c25161dec Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Tue, 1 Oct 2024 14:28:17 -0400 Subject: [PATCH 26/27] rm unnecessary logging --- app/helpers/export_helper.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb index 441c04fa..adc48140 100644 --- a/app/helpers/export_helper.rb +++ b/app/helpers/export_helper.rb @@ -116,7 +116,6 @@ def self.order_highlights(highlights, document_kind, content_html) y = (drawing["top"] / 50).floor() # then sort by x, unmodified x = drawing["left"] - puts "#{hl[:title]}: #{([y, x]).to_s}" [y, x] } end From 9a96ee40a4621c8f70b9e68312c0ee9ad9198c6a Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Thu, 10 Oct 2024 16:59:51 -0400 Subject: [PATCH 27/27] Add support for multicolumn layout and margins in export --- app/views/exports/page.html.erb | 7 +++++++ app/workers/export_project_worker.rb | 9 ++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/views/exports/page.html.erb b/app/views/exports/page.html.erb index 29edbe2f..751c9ea8 100644 --- a/app/views/exports/page.html.erb +++ b/app/views/exports/page.html.erb @@ -2,6 +2,13 @@ body { font-family: Roboto, sans-serif; } + main { + margin-top: <%= @style[:margin_top] %>px; + margin-right: <%= @style[:margin_right] %>px; + margin-bottom: <%= @style[:margin_bottom] %>px; + margin-left: <%= @style[:margin_left] %>px; + column-count: <%= @style[:column_count] || '1' %>; + } li:target, a.dm-highlight:target { box-shadow: 0 0px 8px blue, 0 0px 8px blue, 0 0px 8px blue; diff --git a/app/workers/export_project_worker.rb b/app/workers/export_project_worker.rb index ba339f11..a0efdd74 100644 --- a/app/workers/export_project_worker.rb +++ b/app/workers/export_project_worker.rb @@ -99,7 +99,6 @@ def write_zip_entries(entries, path, zipfile, depth) if child.document_kind == "text" # text page - # TODO: handle multicolumn layout? # render text documents from prosemirror/storyblok to html renderer = Storyblok::Richtext::HtmlRenderer.new renderer.add_mark(Storyblok::Richtext::Marks::Color) @@ -134,6 +133,14 @@ def write_zip_entries(entries, path, zipfile, depth) document_kind: child.document_kind, depth: depth, title: child.title, + # handle multicolumn layout and margins + style: { + column_count: child[:content]["columnCount"].present? ? child[:content]["columnCount"] : 1, + margin_top: child[:content]["marginTop"].present? ? child[:content]["marginTop"] : 0, + margin_right: child[:content]["marginRight"].present? ? child[:content]["marginRight"] : 0, + margin_bottom: child[:content]["marginBottom"].present? ? child[:content]["marginBottom"] : 0, + margin_left: child[:content]["marginLeft"].present? ? child[:content]["marginLeft"] : 0, + } }, ) @current += 1