mirror of
https://github.com/scinote-eln/scinote-web.git
synced 2024-09-21 07:26:15 +08:00
Merge pull request #1308 from mz3944/mz-SCI-2641
Export all - PDF (or HTML) report [SCI-2641] [WIP]
This commit is contained in:
commit
91f2ab62bb
|
@ -68,7 +68,7 @@ label {
|
||||||
}
|
}
|
||||||
|
|
||||||
.ht_clone_top,.ht_clone_left,.ht_clone_corner {
|
.ht_clone_top,.ht_clone_left,.ht_clone_corner {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -323,8 +323,14 @@ label {
|
||||||
|
|
||||||
// Result table element style
|
// Result table element style
|
||||||
.report-result-table-element {
|
.report-result-table-element {
|
||||||
|
.report-element-header {
|
||||||
|
.table-name {
|
||||||
|
margin-left: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.report-element-body {
|
.report-element-body {
|
||||||
padding-top: 30px;
|
padding-top: 15px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -493,6 +499,14 @@ label {
|
||||||
.repository-name {
|
.repository-name {
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-name {
|
||||||
|
margin-left: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-element-body {
|
||||||
|
padding-top: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover > .report-element-header {
|
&:hover > .report-element-header {
|
||||||
|
|
|
@ -1,12 +1,25 @@
|
||||||
module ReportsHelper
|
module ReportsHelper
|
||||||
|
include StringUtility
|
||||||
|
|
||||||
def render_new_element(hide)
|
def render_new_element(hide)
|
||||||
render partial: 'reports/elements/new_element.html.erb',
|
render partial: 'reports/elements/new_element.html.erb',
|
||||||
locals: { hide: hide }
|
locals: { hide: hide }
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_report_element(element, provided_locals = nil)
|
def render_report_element(element, provided_locals = nil)
|
||||||
children_html = ''.html_safe
|
# Determine partial
|
||||||
|
|
||||||
|
file_name = element.type_of
|
||||||
|
if element.type_of.in? ReportExtends::MY_MODULE_CHILDREN_ELEMENTS
|
||||||
|
file_name = "my_module_#{element.type_of.singularize}"
|
||||||
|
end
|
||||||
|
view = "reports/elements/#{file_name}_element.html.erb"
|
||||||
|
|
||||||
|
# Set locals
|
||||||
|
|
||||||
|
locals = provided_locals.nil? ? {} : provided_locals.clone
|
||||||
|
|
||||||
|
children_html = ''.html_safe
|
||||||
# First, recursively render element's children
|
# First, recursively render element's children
|
||||||
if element.comments? || element.project_header?
|
if element.comments? || element.project_header?
|
||||||
# Render no children
|
# Render no children
|
||||||
|
@ -31,25 +44,54 @@ module ReportsHelper
|
||||||
end
|
end
|
||||||
children_html.safe_concat render_new_element(false)
|
children_html.safe_concat render_new_element(false)
|
||||||
end
|
end
|
||||||
|
|
||||||
file_name = element.type_of
|
|
||||||
if element.type_of.in? ReportExtends::MY_MODULE_CHILDREN_ELEMENTS
|
|
||||||
file_name = "my_module_#{element.type_of.singularize}"
|
|
||||||
end
|
|
||||||
view = "reports/elements/#{file_name}_element.html.erb"
|
|
||||||
|
|
||||||
locals = provided_locals.nil? ? {} : provided_locals.clone
|
|
||||||
locals[:children] = children_html
|
locals[:children] = children_html
|
||||||
|
|
||||||
# ReportExtends is located in config/initializers/extends/report_extends.rb
|
if provided_locals[:export_all]
|
||||||
|
# Set path and filename locals for files and tables in export all ZIP
|
||||||
|
|
||||||
|
if element['type_of'] == 'my_module_repository'
|
||||||
|
obj_id = element[:repository_id]
|
||||||
|
elsif element['type_of'].in? %w(step_asset step_table result_asset
|
||||||
|
result_table)
|
||||||
|
|
||||||
|
parent_el = ReportElement.find(element['parent_id'])
|
||||||
|
parent_type = parent_el[:type_of]
|
||||||
|
parent = parent_type.singularize.classify.constantize
|
||||||
|
.find(parent_el["#{parent_type}_id"])
|
||||||
|
|
||||||
|
if parent.class == Step
|
||||||
|
obj_id = if element['type_of'] == 'step_asset'
|
||||||
|
element[:asset_id]
|
||||||
|
elsif element['type_of'] == 'step_table'
|
||||||
|
element[:table_id]
|
||||||
|
end
|
||||||
|
elsif parent.class == MyModule
|
||||||
|
result = Result.find(element[:result_id])
|
||||||
|
obj_id = if element['type_of'] == 'result_asset'
|
||||||
|
result.asset.id
|
||||||
|
elsif element['type_of'] == 'result_table'
|
||||||
|
result.table.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if obj_id
|
||||||
|
locals[:path] =
|
||||||
|
provided_locals[:obj_filenames][element['type_of'].to_sym][obj_id]
|
||||||
|
.sub(%r{/usr/src/app/tmp/temp-zip-\d+/}, '')
|
||||||
|
locals[:filename] = locals[:path].split('/').last
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ReportExtends is located in config/initializers/extends/report_extends.rb
|
||||||
ReportElement.type_ofs.keys.each do |type|
|
ReportElement.type_ofs.keys.each do |type|
|
||||||
next unless element.public_send("#{type}?")
|
next unless element.public_send("#{type}?")
|
||||||
element.element_references.each do |el_ref|
|
element.element_references.each do |el_ref|
|
||||||
locals[el_ref.class.name.underscore.to_sym] = el_ref
|
locals[el_ref.class.name.underscore.to_sym] = el_ref
|
||||||
end
|
end
|
||||||
locals[:order] = element
|
if type.in? ReportExtends::SORTED_ELEMENTS
|
||||||
.sort_order if type.in? ReportExtends::SORTED_ELEMENTS
|
locals[:order] = element.sort_order
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
(render partial: view, locals: locals).html_safe
|
(render partial: view, locals: locals).html_safe
|
||||||
|
@ -114,4 +156,19 @@ module ReportsHelper
|
||||||
end
|
end
|
||||||
html_doc.to_s
|
html_doc.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def obj_name_to_filename(obj, filename_suffix = '')
|
||||||
|
obj_name = if obj.class == Asset
|
||||||
|
obj_name, extension = obj.file_file_name.split('.')
|
||||||
|
extension&.prepend('.')
|
||||||
|
obj_name
|
||||||
|
elsif obj.class.in? [Table, Result, Repository]
|
||||||
|
extension = '.csv'
|
||||||
|
obj.name.present? ? obj.name : obj.class.name
|
||||||
|
end
|
||||||
|
obj_name = to_filesystem_name(obj_name)
|
||||||
|
obj_name + "#{filename_suffix}#{extension}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module StringUtility
|
module StringUtility
|
||||||
def ellipsize(
|
def ellipsize(
|
||||||
string,
|
string,
|
||||||
|
@ -10,4 +12,25 @@ module StringUtility
|
||||||
mid_length = length - edge_length * 2
|
mid_length = length - edge_length * 2
|
||||||
string.gsub(/(#{edge}).{#{mid_length},}(#{edge})/, '\1...\2')
|
string.gsub(/(#{edge}).{#{mid_length},}(#{edge})/, '\1...\2')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Convert string to filesystem compatible file/folder name
|
||||||
|
def to_filesystem_name(name)
|
||||||
|
# Handle reserved directories
|
||||||
|
if name == '..'
|
||||||
|
return '__'
|
||||||
|
elsif name == '.'
|
||||||
|
return '_'
|
||||||
|
end
|
||||||
|
|
||||||
|
# Truncate and replace reserved characters
|
||||||
|
name = name[0, Constants::EXPORTED_FILENAME_TRUNCATION_LENGTH]
|
||||||
|
.gsub(%r{[*":<>?/\\|~]}, '_')
|
||||||
|
|
||||||
|
# Remove control characters
|
||||||
|
name = name.chars.map(&:ord).select { |s| (s > 31 && s < 127) || s > 127 }
|
||||||
|
.pack('U*')
|
||||||
|
|
||||||
|
# Remove leading hyphens, trailing dots/spaces
|
||||||
|
name.gsub(/^-|\.+$| +$/, '_')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -221,4 +221,36 @@ class Project < ApplicationRecord
|
||||||
end
|
end
|
||||||
res
|
res
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def generate_report_pdf(user, team, pdf_name, obj_filenames = nil)
|
||||||
|
ActionController::Renderer::RACK_KEY_TRANSLATION['warden'] ||= 'warden'
|
||||||
|
proxy = Warden::Proxy.new({}, Warden::Manager.new({}))
|
||||||
|
renderer = ApplicationController.renderer.new(warden: proxy)
|
||||||
|
|
||||||
|
report = Report.generate_whole_project_report(self, user, team)
|
||||||
|
|
||||||
|
page_html_string =
|
||||||
|
renderer.render 'reports/new.html.erb',
|
||||||
|
locals: { export_all: true,
|
||||||
|
obj_filenames: obj_filenames },
|
||||||
|
assigns: { project: self, report: report }
|
||||||
|
parsed_page_html = Nokogiri::HTML(page_html_string)
|
||||||
|
parsed_pdf_html = parsed_page_html.at_css('#report-content')
|
||||||
|
report.destroy
|
||||||
|
|
||||||
|
parsed_pdf = ApplicationController.render(
|
||||||
|
pdf: pdf_name,
|
||||||
|
header: { right: '[page] of [topage]' },
|
||||||
|
locals: { content: parsed_pdf_html.to_s },
|
||||||
|
template: 'reports/report.pdf.erb',
|
||||||
|
disable_javascript: true,
|
||||||
|
disable_internal_links: false,
|
||||||
|
current_user: user,
|
||||||
|
current_team: team
|
||||||
|
)
|
||||||
|
# Dirty workaround to convert absolute links back to relative ones, since
|
||||||
|
# WickedPdf does the opposite, based on the path where the file parsing is
|
||||||
|
# done
|
||||||
|
parsed_pdf.gsub('/URI (file:////tmp/', '/URI (')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -20,7 +20,7 @@ class Report < ApplicationRecord
|
||||||
|
|
||||||
# Report either has many report elements (if grouped by timestamp),
|
# Report either has many report elements (if grouped by timestamp),
|
||||||
# or many module elements (if grouped by module)
|
# or many module elements (if grouped by module)
|
||||||
has_many :report_elements, inverse_of: :report, dependent: :destroy
|
has_many :report_elements, inverse_of: :report, dependent: :delete_all
|
||||||
|
|
||||||
after_commit do
|
after_commit do
|
||||||
Views::Datatables::DatatablesReport.refresh_materialized_view
|
Views::Datatables::DatatablesReport.refresh_materialized_view
|
||||||
|
@ -93,6 +93,103 @@ class Report < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.generate_whole_project_report(project, current_user, current_team)
|
||||||
|
report_contents = gen_element_content(project, nil, 'project_header', true)
|
||||||
|
|
||||||
|
project.experiments.each do |exp|
|
||||||
|
modules = []
|
||||||
|
|
||||||
|
exp.my_modules.each do |my_module|
|
||||||
|
module_children = []
|
||||||
|
|
||||||
|
my_module.protocol.steps.each do |step|
|
||||||
|
step_children =
|
||||||
|
gen_element_content(step, step.assets, 'step_asset')
|
||||||
|
step_children +=
|
||||||
|
gen_element_content(step, step.tables, 'step_table')
|
||||||
|
step_children +=
|
||||||
|
gen_element_content(step, step.checklists, 'step_checklist')
|
||||||
|
step_children +=
|
||||||
|
gen_element_content(step, nil, 'step_comments', true, 'asc')
|
||||||
|
|
||||||
|
module_children +=
|
||||||
|
gen_element_content(step, nil, 'step', true, nil, step_children)
|
||||||
|
end
|
||||||
|
|
||||||
|
my_module.results.each do |result|
|
||||||
|
result_children =
|
||||||
|
gen_element_content(result, nil, 'result_comments', true, 'asc')
|
||||||
|
|
||||||
|
result_type = if result.asset
|
||||||
|
'result_asset'
|
||||||
|
elsif result.table
|
||||||
|
'result_table'
|
||||||
|
elsif result.result_text
|
||||||
|
'result_text'
|
||||||
|
end
|
||||||
|
module_children +=
|
||||||
|
gen_element_content(result, nil, result_type, true, nil,
|
||||||
|
result_children)
|
||||||
|
end
|
||||||
|
|
||||||
|
module_children +=
|
||||||
|
gen_element_content(my_module, nil, 'my_module_activity', true, 'asc')
|
||||||
|
module_children +=
|
||||||
|
gen_element_content(my_module,
|
||||||
|
my_module.repository_rows.select(:repository_id)
|
||||||
|
.distinct.map(&:repository),
|
||||||
|
'my_module_repository', true, 'asc')
|
||||||
|
|
||||||
|
modules +=
|
||||||
|
gen_element_content(my_module, nil, 'my_module', true, nil,
|
||||||
|
module_children)
|
||||||
|
end
|
||||||
|
|
||||||
|
report_contents +=
|
||||||
|
gen_element_content(exp, nil, 'experiment', true, nil, modules)
|
||||||
|
end
|
||||||
|
|
||||||
|
report = Report.new
|
||||||
|
report.name = loop do
|
||||||
|
dummy_name = SecureRandom.hex(10)
|
||||||
|
break dummy_name unless Report.where(name: dummy_name).exists?
|
||||||
|
end
|
||||||
|
report.project = project
|
||||||
|
report.user = current_user
|
||||||
|
report.team = current_team
|
||||||
|
report.last_modified_by = current_user
|
||||||
|
report.save_with_contents(report_contents)
|
||||||
|
report
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.gen_element_content(parent_obj, association_objs, type_of,
|
||||||
|
use_parent_id = false, sort_order = nil,
|
||||||
|
children = nil)
|
||||||
|
parent_type = parent_obj.class.name.underscore
|
||||||
|
type = type_of.split('_').last.singularize
|
||||||
|
extra_id_needed = use_parent_id && !association_objs.nil?
|
||||||
|
elements = []
|
||||||
|
|
||||||
|
association_objs ||= [nil]
|
||||||
|
association_objs.each do |obj|
|
||||||
|
elements << {
|
||||||
|
'type_of' => type_of,
|
||||||
|
'id' => {}.tap do |ids_hash|
|
||||||
|
if use_parent_id
|
||||||
|
ids_hash["#{parent_type}_id"] = parent_obj.id
|
||||||
|
else
|
||||||
|
ids_hash["#{type}_id"] = obj.id
|
||||||
|
end
|
||||||
|
ids_hash["#{type}_id"] = obj.id if extra_id_needed
|
||||||
|
end,
|
||||||
|
'sort_order' => sort_order.present? ? sort_order : nil,
|
||||||
|
'children' => children.present? ? children : []
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
elements
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Recursively save a single JSON element
|
# Recursively save a single JSON element
|
||||||
|
|
|
@ -1,17 +1,18 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require 'zip'
|
require 'zip'
|
||||||
require 'fileutils'
|
require 'fileutils'
|
||||||
require 'csv'
|
require 'csv'
|
||||||
|
|
||||||
class TeamZipExport < ZipExport
|
class TeamZipExport < ZipExport
|
||||||
|
include StringUtility
|
||||||
|
|
||||||
has_attached_file :zip_file,
|
has_attached_file :zip_file,
|
||||||
path: '/zip_exports/:attachment/:id_partition/' \
|
path: '/zip_exports/:attachment/:id_partition/' \
|
||||||
':hash/:style/:filename'
|
':hash/:style/:filename'
|
||||||
validates_attachment :zip_file,
|
validates_attachment :zip_file,
|
||||||
content_type: { content_type: 'application/zip' }
|
content_type: { content_type: 'application/zip' }
|
||||||
|
|
||||||
# Length of allowed name size
|
|
||||||
MAX_NAME_SIZE = 20
|
|
||||||
|
|
||||||
def generate_exportable_zip(user, data, type, options = {})
|
def generate_exportable_zip(user, data, type, options = {})
|
||||||
@user = user
|
@user = user
|
||||||
FileUtils.mkdir_p(File.join(Rails.root, 'tmp/zip-ready'))
|
FileUtils.mkdir_p(File.join(Rails.root, 'tmp/zip-ready'))
|
||||||
|
@ -34,10 +35,10 @@ class TeamZipExport < ZipExport
|
||||||
private
|
private
|
||||||
|
|
||||||
# Export all functionality
|
# Export all functionality
|
||||||
def generate_teams_zip(tmp_dir, data, options = {})
|
def generate_team_zip(tmp_dir, data, options = {})
|
||||||
# Create team folder
|
# Create team folder
|
||||||
@team = options[:team]
|
@team = options[:team]
|
||||||
team_path = "#{tmp_dir}/#{handle_name(@team.name)}"
|
team_path = "#{tmp_dir}/#{to_filesystem_name(@team.name)}"
|
||||||
FileUtils.mkdir_p(team_path)
|
FileUtils.mkdir_p(team_path)
|
||||||
|
|
||||||
# Create Projects folders
|
# Create Projects folders
|
||||||
|
@ -46,7 +47,10 @@ class TeamZipExport < ZipExport
|
||||||
|
|
||||||
# Iterate through every project
|
# Iterate through every project
|
||||||
data.each_with_index do |(_, p), ind|
|
data.each_with_index do |(_, p), ind|
|
||||||
project_name = handle_name(p.name) + "_#{ind}"
|
obj_filenames = { my_module_repository: {}, step_asset: {},
|
||||||
|
step_table: {}, result_asset: {}, result_table: {} }
|
||||||
|
|
||||||
|
project_name = to_filesystem_name(p.name) + "_#{ind}"
|
||||||
root =
|
root =
|
||||||
if p.archived
|
if p.archived
|
||||||
"#{team_path}/Archived projects"
|
"#{team_path}/Archived projects"
|
||||||
|
@ -56,8 +60,6 @@ class TeamZipExport < ZipExport
|
||||||
root += "/#{project_name}"
|
root += "/#{project_name}"
|
||||||
FileUtils.mkdir_p(root)
|
FileUtils.mkdir_p(root)
|
||||||
|
|
||||||
FileUtils.touch("#{root}/#{project_name}_Report.pdf").first
|
|
||||||
|
|
||||||
inventories = "#{root}/Inventories"
|
inventories = "#{root}/Inventories"
|
||||||
FileUtils.mkdir_p(inventories)
|
FileUtils.mkdir_p(inventories)
|
||||||
|
|
||||||
|
@ -70,18 +72,19 @@ class TeamZipExport < ZipExport
|
||||||
# Iterate through every inventory repo and save it to CSV
|
# Iterate through every inventory repo and save it to CSV
|
||||||
repo_rows.map(&:repository).uniq.each_with_index do |repo, repo_idx|
|
repo_rows.map(&:repository).uniq.each_with_index do |repo, repo_idx|
|
||||||
curr_repo_rows = repo_rows.select { |x| x.repository_id == repo.id }
|
curr_repo_rows = repo_rows.select { |x| x.repository_id == repo.id }
|
||||||
save_inventories_to_csv(inventories, repo, curr_repo_rows, repo_idx)
|
obj_filenames[:my_module_repository][repo.id] =
|
||||||
|
save_inventories_to_csv(inventories, repo, curr_repo_rows, repo_idx)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Include all experiments
|
# Include all experiments
|
||||||
p.experiments.each_with_index do |ex, ex_ind|
|
p.experiments.each_with_index do |ex, ex_ind|
|
||||||
experiment_path = "#{root}/#{handle_name(ex.name)}_#{ex_ind}"
|
experiment_path = "#{root}/#{to_filesystem_name(ex.name)}_#{ex_ind}"
|
||||||
FileUtils.mkdir_p(experiment_path)
|
FileUtils.mkdir_p(experiment_path)
|
||||||
|
|
||||||
# Include all modules
|
# Include all modules
|
||||||
ex.my_modules.each_with_index do |my_module, mod_ind|
|
ex.my_modules.each_with_index do |my_module, mod_ind|
|
||||||
my_module_path = "#{experiment_path}/" \
|
my_module_path = "#{experiment_path}/" \
|
||||||
"#{handle_name(my_module.name)}_#{mod_ind}"
|
"#{to_filesystem_name(my_module.name)}_#{mod_ind}"
|
||||||
FileUtils.mkdir_p(my_module_path)
|
FileUtils.mkdir_p(my_module_path)
|
||||||
|
|
||||||
# Create upper directories for both elements
|
# Create upper directories for both elements
|
||||||
|
@ -92,16 +95,31 @@ class TeamZipExport < ZipExport
|
||||||
|
|
||||||
# Export protocols
|
# Export protocols
|
||||||
steps = my_module.protocols.map(&:steps).flatten
|
steps = my_module.protocols.map(&:steps).flatten
|
||||||
export_assets(StepAsset.where(step: steps), :step, protocol_path)
|
obj_filenames[:step_asset].merge!(
|
||||||
export_tables(StepTable.where(step: steps), :step, protocol_path)
|
export_assets(StepAsset.where(step: steps), :step, protocol_path)
|
||||||
|
)
|
||||||
|
obj_filenames[:step_table].merge!(
|
||||||
|
export_tables(StepTable.where(step: steps), :step, protocol_path)
|
||||||
|
)
|
||||||
|
|
||||||
# Export results
|
# Export results
|
||||||
export_assets(ResultAsset.where(result: my_module.results),
|
obj_filenames[:result_asset].merge!(
|
||||||
:result, result_path)
|
export_assets(ResultAsset.where(result: my_module.results),
|
||||||
export_tables(ResultTable.where(result: my_module.results),
|
:result, result_path)
|
||||||
:result, result_path)
|
)
|
||||||
|
obj_filenames[:result_table].merge!(
|
||||||
|
export_tables(ResultTable.where(result: my_module.results),
|
||||||
|
:result, result_path)
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Generate and export whole project report PDF
|
||||||
|
pdf_name = "#{project_name}_Report.pdf"
|
||||||
|
project_report_pdf =
|
||||||
|
p.generate_report_pdf(@user, @team, pdf_name, obj_filenames)
|
||||||
|
file = FileUtils.touch("#{root}/#{pdf_name}").first
|
||||||
|
File.open(file, 'wb') { |f| f.write(project_report_pdf) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -119,25 +137,6 @@ class TeamZipExport < ZipExport
|
||||||
UserNotification.create(notification: notification, user: user)
|
UserNotification.create(notification: notification, user: user)
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_name(name)
|
|
||||||
# Handle reserved directories
|
|
||||||
if name == '..'
|
|
||||||
return '__'
|
|
||||||
elsif name == '.'
|
|
||||||
return '_'
|
|
||||||
end
|
|
||||||
|
|
||||||
# Truncate and replace reserved characters
|
|
||||||
name = name[0, MAX_NAME_SIZE].gsub(%r{[*":<>?/\\|~]}, '_')
|
|
||||||
|
|
||||||
# Remove control characters
|
|
||||||
name = name.chars.map(&:ord).select { |s| (s > 31 && s < 127) || s > 127 }
|
|
||||||
.pack('U*')
|
|
||||||
|
|
||||||
# Remove leading hyphens, trailing dots/spaces
|
|
||||||
name.gsub(/^-|\.+$| +$/, '_')
|
|
||||||
end
|
|
||||||
|
|
||||||
# Appends given suffix to file_name and then adds original extension
|
# Appends given suffix to file_name and then adds original extension
|
||||||
def append_file_suffix(file_name, suffix)
|
def append_file_suffix(file_name, suffix)
|
||||||
ext = File.extname(file_name)
|
ext = File.extname(file_name)
|
||||||
|
@ -146,8 +145,11 @@ class TeamZipExport < ZipExport
|
||||||
|
|
||||||
# Helper method to extract given assets to the directory
|
# Helper method to extract given assets to the directory
|
||||||
def export_assets(elements, type, directory)
|
def export_assets(elements, type, directory)
|
||||||
|
asset_indexes = {}
|
||||||
|
|
||||||
elements.each_with_index do |element, i|
|
elements.each_with_index do |element, i|
|
||||||
asset = element.asset
|
asset = element.asset
|
||||||
|
|
||||||
if type == :step
|
if type == :step
|
||||||
name = "#{directory}/" \
|
name = "#{directory}/" \
|
||||||
"#{append_file_suffix(asset.file_file_name,
|
"#{append_file_suffix(asset.file_file_name,
|
||||||
|
@ -158,37 +160,48 @@ class TeamZipExport < ZipExport
|
||||||
end
|
end
|
||||||
file = FileUtils.touch(name).first
|
file = FileUtils.touch(name).first
|
||||||
File.open(file, 'wb') { |f| f.write(asset.open.read) }
|
File.open(file, 'wb') { |f| f.write(asset.open.read) }
|
||||||
|
asset_indexes[asset.id] = name
|
||||||
end
|
end
|
||||||
|
|
||||||
|
asset_indexes
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper method to extract given tables to the directory
|
# Helper method to extract given tables to the directory
|
||||||
def export_tables(elements, type, directory)
|
def export_tables(elements, type, directory)
|
||||||
|
table_indexes = {}
|
||||||
|
|
||||||
elements.each_with_index do |element, i|
|
elements.each_with_index do |element, i|
|
||||||
table = element.table
|
table = element.table
|
||||||
table_name = table.name.presence || 'Table'
|
table_name = table.name.presence || 'Table'
|
||||||
table_name += i.to_s
|
table_name += i.to_s
|
||||||
|
|
||||||
if type == :step
|
if type == :step
|
||||||
name = "#{directory}/#{handle_name(table_name)}" \
|
name = "#{directory}/#{to_filesystem_name(table_name)}" \
|
||||||
"_#{i}_Step#{element.step.position + 1}.csv"
|
"_#{i}_Step#{element.step.position + 1}.csv"
|
||||||
elsif type == :result
|
elsif type == :result
|
||||||
name = "#{directory}/#{handle_name(table_name)}.csv"
|
name = "#{directory}/#{to_filesystem_name(table_name)}.csv"
|
||||||
end
|
end
|
||||||
file = FileUtils.touch(name).first
|
file = FileUtils.touch(name).first
|
||||||
File.open(file, 'wb') { |f| f.write(table.to_csv) }
|
File.open(file, 'wb') { |f| f.write(table.to_csv) }
|
||||||
|
table_indexes[table.id] = name
|
||||||
end
|
end
|
||||||
|
|
||||||
|
table_indexes
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper method for saving inventories to CSV
|
# Helper method for saving inventories to CSV
|
||||||
def save_inventories_to_csv(path, repo, repo_rows, id)
|
def save_inventories_to_csv(path, repo, repo_rows, id)
|
||||||
repo_name = handle_name(repo.name) + "_#{id}"
|
repo_name = "#{to_filesystem_name(repo.name)}_#{id}"
|
||||||
file = FileUtils.touch("#{path}/#{repo_name}.csv").first
|
|
||||||
|
|
||||||
# Attachment folder
|
# Attachment folder
|
||||||
rel_attach_path = "#{repo_name}_attachments"
|
rel_attach_path = "#{repo_name}_attachments"
|
||||||
attach_path = "#{path}/#{rel_attach_path}"
|
attach_path = "#{path}/#{rel_attach_path}"
|
||||||
FileUtils.mkdir_p(attach_path)
|
FileUtils.mkdir_p(attach_path)
|
||||||
|
|
||||||
|
# CSV file
|
||||||
|
csv_file_path = "#{path}/#{to_filesystem_name(repo.name)}_#{id}.csv"
|
||||||
|
csv_file = FileUtils.touch(csv_file_path).first
|
||||||
|
|
||||||
# Define headers and columns IDs
|
# Define headers and columns IDs
|
||||||
col_ids = [-3, -4, -5, -6] + repo.repository_columns.map(&:id)
|
col_ids = [-3, -4, -5, -6] + repo.repository_columns.map(&:id)
|
||||||
|
|
||||||
|
@ -210,13 +223,15 @@ class TeamZipExport < ZipExport
|
||||||
# Generate CSV
|
# Generate CSV
|
||||||
csv_data = RepositoryZipExport.to_csv(repo_rows, col_ids, @user, @team,
|
csv_data = RepositoryZipExport.to_csv(repo_rows, col_ids, @user, @team,
|
||||||
handle_name_func)
|
handle_name_func)
|
||||||
File.open(file, 'wb') { |f| f.write(csv_data) }
|
File.open(csv_file, 'wb') { |f| f.write(csv_data) }
|
||||||
|
|
||||||
# Save all attachments (it doesn't work directly in callback function
|
# Save all attachments (it doesn't work directly in callback function
|
||||||
assets.each do |asset, asset_path|
|
assets.each do |asset, asset_path|
|
||||||
file = FileUtils.touch(asset_path).first
|
file = FileUtils.touch(asset_path).first
|
||||||
File.open(file, 'wb') { |f| f.write asset.open.read }
|
File.open(file, 'wb') { |f| f.write asset.open.read }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
repo_name
|
||||||
end
|
end
|
||||||
|
|
||||||
# Recursive zipping
|
# Recursive zipping
|
||||||
|
|
|
@ -12,7 +12,7 @@ module TeamZipExporter
|
||||||
zip.generate_exportable_zip(
|
zip.generate_exportable_zip(
|
||||||
current_user,
|
current_user,
|
||||||
ids,
|
ids,
|
||||||
:teams,
|
:team,
|
||||||
options
|
options
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
|
@ -13,6 +13,14 @@
|
||||||
<div class="pull-left repository-name">
|
<div class="pull-left repository-name">
|
||||||
<%=t "projects.reports.elements.module_repository.name", repository: repository.name, my_module: my_module.name %>
|
<%=t "projects.reports.elements.module_repository.name", repository: repository.name, my_module: my_module.name %>
|
||||||
</div>
|
</div>
|
||||||
|
<% if defined? export_all and export_all %>
|
||||||
|
<div class="pull-left table-name">
|
||||||
|
<a href="<%= path %>">
|
||||||
|
<em><%=t "projects.reports.elements.module_repository.table_name",
|
||||||
|
name: filename %></em>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
<div class="pull-right controls">
|
<div class="pull-right controls">
|
||||||
<%= render partial: "reports/elements/element_controls.html.erb", locals: { show_sort: true } %>
|
<%= render partial: "reports/elements/element_controls.html.erb", locals: { show_sort: true } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,25 +1,29 @@
|
||||||
<% if result.blank? and @result.present? then result = @result end %>
|
<% result ||= @result %>
|
||||||
<% asset = result.asset %>
|
<% asset = result.asset %>
|
||||||
<% is_image = result.asset.is_image? %>
|
<% is_image = result.asset.is_image? %>
|
||||||
<% comments = result.result_comments %>
|
<% comments = result.result_comments %>
|
||||||
<% timestamp = asset.created_at %>
|
<% timestamp = asset.created_at %>
|
||||||
<% name = result.name %>
|
|
||||||
<% icon_class = 'fas ' + (is_image ? 'fa-image' : 'fa-file') %>
|
<% icon_class = 'fas ' + (is_image ? 'fa-image' : 'fa-file') %>
|
||||||
<div class="report-element report-result-element report-result-asset-element" data-ts="<%= timestamp.to_i %>" data-type="result_asset" data-id='{ "result_id": <%= result.id %> }' data-scroll-id="<%= result.id %>" data-modal-title="<%=t "projects.reports.elements.modals.result_contents.head_title", result: result.name %>" data-name="<%= name %>" data-icon-class="<%= icon_class %>">
|
<div class="report-element report-result-element report-result-asset-element" data-ts="<%= timestamp.to_i %>" data-type="result_asset" data-id='{ "result_id": <%= result.id %> }' data-scroll-id="<%= result.id %>" data-modal-title="<%=t "projects.reports.elements.modals.result_contents.head_title", result: result.name %>" data-name="<%= result.name %>" data-icon-class="<%= icon_class %>">
|
||||||
<div class="report-element-header">
|
<div class="report-element-header">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="pull-left result-icon">
|
<div class="pull-left result-icon">
|
||||||
<span class="<%= icon_class %>"></span>
|
<span class="<%= icon_class %>"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pull-left result-name">
|
<div class="pull-left result-name">
|
||||||
<%= name %>
|
<%= result.name %>
|
||||||
</div>
|
</div>
|
||||||
<div class="pull-left file-name">
|
<div class="pull-left file-name">
|
||||||
<em><%=t "projects.reports.elements.result_asset.file_name",
|
<% if defined? export_all and export_all %>
|
||||||
file: truncate(asset.file_file_name,
|
<a href="<%= path %>">
|
||||||
length: Constants::FILENAME_TRUNCATION_LENGTH)
|
<em><%=t "projects.reports.elements.result_asset.file_name",
|
||||||
%>
|
file: filename %></em>
|
||||||
</em>
|
</a>
|
||||||
|
<% else %>
|
||||||
|
<em><%=t "projects.reports.elements.result_asset.file_name",
|
||||||
|
file: truncate(asset.file_file_name,
|
||||||
|
length: Constants::FILENAME_TRUNCATION_LENGTH) %></em>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="pull-left user-time">
|
<div class="pull-left user-time">
|
||||||
<%=t "projects.reports.elements.result_asset.user_time", user: result.user.full_name, timestamp: l(timestamp, format: :full) %>
|
<%=t "projects.reports.elements.result_asset.user_time", user: result.user.full_name, timestamp: l(timestamp, format: :full) %>
|
||||||
|
|
|
@ -1,24 +1,31 @@
|
||||||
<% if result.blank? and @result.present? then result = @result end %>
|
<% result ||= @result %>
|
||||||
<% table = result.table %>
|
<% table = result.table %>
|
||||||
<% comments = result.result_comments %>
|
<% comments = result.result_comments %>
|
||||||
<% timestamp = table.created_at %>
|
<% timestamp = table.created_at %>
|
||||||
<% name = result.name %>
|
<div class="report-element report-result-element report-result-table-element" data-ts="<%= timestamp.to_i %>" data-type="result_table" data-id='{ "result_id": <%= result.id %> }' data-scroll-id="<%= result.id %>" data-modal-title="<%=t "projects.reports.elements.modals.result_contents.head_title", result: result.name %>" data-name="<%= result.name %>" data-icon-class="fas fa-table">
|
||||||
<div class="report-element report-result-element report-result-table-element" data-ts="<%= timestamp.to_i %>" data-type="result_table" data-id='{ "result_id": <%= result.id %> }' data-scroll-id="<%= result.id %>" data-modal-title="<%=t "projects.reports.elements.modals.result_contents.head_title", result: result.name %>" data-name="<%= name %>" data-icon-class="fas fa-table">
|
|
||||||
<div class="report-element-header">
|
<div class="report-element-header">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="pull-left result-name-container">
|
<div class="pull-left result-name-container">
|
||||||
<div class="result-icon">
|
<div class="pull-left result-icon">
|
||||||
<span class="fas fa-table"></span>
|
<span class="fas fa-table"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="result-name">
|
<div class="pull-left result-name">
|
||||||
<%= name %>
|
<%= result.name %>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-time">
|
<% if defined? export_all and export_all %>
|
||||||
|
<div class="pull-left table-name">
|
||||||
|
<a href="<%= path %>">
|
||||||
|
<em><%=t "projects.reports.elements.result_table.table_name",
|
||||||
|
name: filename %></em>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<div class="pull-left user-time">
|
||||||
<%=t "projects.reports.elements.result_table.user_time", user: result.user.full_name , timestamp: l(timestamp, format: :full) %>
|
<%=t "projects.reports.elements.result_table.user_time", user: result.user.full_name , timestamp: l(timestamp, format: :full) %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="pull-right controls">
|
||||||
<div class="pull-right controls">
|
<%= render partial: "reports/elements/element_controls.html.erb" %>
|
||||||
<%= render partial: "reports/elements/element_controls.html.erb" %>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<% if asset.blank? and @asset.present? then asset = @asset end %>
|
<% asset ||= @asset %>
|
||||||
<% is_image = asset.is_image? %>
|
<% is_image = asset.is_image? %>
|
||||||
<% timestamp = asset.created_at %>
|
<% timestamp = asset.created_at %>
|
||||||
<% icon_class = 'fas ' + (is_image ? 'fa-image' : 'fa-file') %>
|
<% icon_class = 'fas ' + (is_image ? 'fa-image' : 'fa-file') %>
|
||||||
|
@ -9,7 +9,16 @@
|
||||||
<span class="<%= icon_class %>"></span>
|
<span class="<%= icon_class %>"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pull-left file-name">
|
<div class="pull-left file-name">
|
||||||
<em><%=t 'projects.reports.elements.step_asset.file_name', file: truncate( asset.file_file_name, length: Constants::FILENAME_TRUNCATION_LENGTH) %></em>
|
<% if defined? export_all and export_all %>
|
||||||
|
<a href="<%= path %>">
|
||||||
|
<em><%=t 'projects.reports.elements.step_asset.file_name',
|
||||||
|
file: filename %></em>
|
||||||
|
</a>
|
||||||
|
<% else %>
|
||||||
|
<em><%=t 'projects.reports.elements.step_asset.file_name',
|
||||||
|
file: truncate(asset.file_file_name,
|
||||||
|
length: Constants::FILENAME_TRUNCATION_LENGTH) %></em>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="pull-left user-time">
|
<div class="pull-left user-time">
|
||||||
<%=t 'projects.reports.elements.step_asset.user_time', timestamp: l(timestamp, format: :full) %>
|
<%=t 'projects.reports.elements.step_asset.user_time', timestamp: l(timestamp, format: :full) %>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<% if table.blank? and @table.present? then table = @table end %>
|
<% table ||= @table %>
|
||||||
<% timestamp = table.created_at %>
|
<% timestamp = table.created_at %>
|
||||||
<div class="report-element report-step-attachment-element report-step-table-element" data-ts="<%= timestamp.to_i %>" data-type="step_table" data-id='{ "table_id": <%= table.id %> }' data-scroll-id="<%= table.id %>" data-name="<%= table.name %>" data-icon-class="fas fa-table">
|
<div class="report-element report-step-attachment-element report-step-table-element" data-ts="<%= timestamp.to_i %>" data-type="step_table" data-id='{ "table_id": <%= table.id %> }' data-scroll-id="<%= table.id %>" data-name="<%= table.name %>" data-icon-class="fas fa-table">
|
||||||
<div class="report-element-header">
|
<div class="report-element-header">
|
||||||
|
@ -6,11 +6,20 @@
|
||||||
<div class="pull-left attachment-icon">
|
<div class="pull-left attachment-icon">
|
||||||
<span class="fas fa-table"></span>
|
<span class="fas fa-table"></span>
|
||||||
</div>
|
</div>
|
||||||
<% if table && table.name %>
|
<div class="pull-left table-name">
|
||||||
<div class="pull-left table-name">
|
<% if defined? export_all and export_all %>
|
||||||
<em><%=t 'projects.reports.elements.step_table.table_name', name: table.name %></em>
|
<a href="<%= path %>">
|
||||||
</div>
|
<em><%=t 'projects.reports.elements.step_table.table_name',
|
||||||
<% end %>
|
name: filename %></em>
|
||||||
|
</a>
|
||||||
|
<% else %>
|
||||||
|
<% if table.try(:name) %>
|
||||||
|
<em><%=t 'projects.reports.elements.step_table.table_name',
|
||||||
|
name: truncate(table.name,
|
||||||
|
length: Constants::FILENAME_TRUNCATION_LENGTH) %></em>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
<div class="pull-left user-time">
|
<div class="pull-left user-time">
|
||||||
<%=t 'projects.reports.elements.step_table.user_time', timestamp: l(timestamp, format: :full) %>
|
<%=t 'projects.reports.elements.step_table.user_time', timestamp: l(timestamp, format: :full) %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -14,15 +14,15 @@
|
||||||
id="data-holder"
|
id="data-holder"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
data-project-modal-title="<%=t "projects.reports.elements.modals.project_contents.head_title" %>"
|
data-project-modal-title="<%=t "projects.reports.elements.modals.project_contents.head_title" %>"
|
||||||
data-add-project-contents-url="<%= project_contents_modal_project_reports_url %>"
|
data-add-project-contents-url="<%= project_contents_modal_project_reports_url(@project) %>"
|
||||||
data-add-experiment-contents-url="<%= experiment_contents_modal_project_reports_url %>"
|
data-add-experiment-contents-url="<%= experiment_contents_modal_project_reports_url(@project) %>"
|
||||||
data-add-module-contents-url="<%= module_contents_modal_project_reports_url %>"
|
data-add-module-contents-url="<%= module_contents_modal_project_reports_url(@project) %>"
|
||||||
data-add-step-contents-url="<%= step_contents_modal_project_reports_url %>"
|
data-add-step-contents-url="<%= step_contents_modal_project_reports_url(@project) %>"
|
||||||
data-add-result-contents-url="<%= result_contents_modal_project_reports_url %>"
|
data-add-result-contents-url="<%= result_contents_modal_project_reports_url(@project) %>"
|
||||||
data-stylesheet-url="<%= stylesheet_path "application" %>"
|
data-stylesheet-url="<%= stylesheet_path "application" %>"
|
||||||
data-print-title="<%=t "projects.reports.print_title", project: @project.name %>"
|
data-print-title="<%=t "projects.reports.print_title", project: @project.name %>"
|
||||||
data-project-id="<%= @project.id %>"
|
data-project-id="<%= @project.id %>"
|
||||||
data-save-report-url="<%= save_modal_project_reports_url %>"
|
data-save-report-url="<%= save_modal_project_reports_url(@project) %>"
|
||||||
data-report-id="<%= @report.present? ? @report.id : "" %>"
|
data-report-id="<%= @report.present? ? @report.id : "" %>"
|
||||||
data-unsaved-work-text="<%=t "projects.reports.new.unsaved_work" %>"
|
data-unsaved-work-text="<%=t "projects.reports.new.unsaved_work" %>"
|
||||||
data-global-sort-text="<%=t "projects.reports.new.global_sort" %>"></div>
|
data-global-sort-text="<%=t "projects.reports.new.global_sort" %>"></div>
|
||||||
|
@ -32,7 +32,7 @@
|
||||||
|
|
||||||
<% if @report.present? %>
|
<% if @report.present? %>
|
||||||
<% @report.root_elements.each do |el| %>
|
<% @report.root_elements.each do |el| %>
|
||||||
<%= render_report_element(el) %>
|
<%= render_report_element(el, local_assigns) %>
|
||||||
<%= render_new_element(false) %>
|
<%= render_new_element(false) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|
|
@ -25,6 +25,9 @@ class Constants
|
||||||
DROPDOWN_TEXT_MAX_LENGTH = 15
|
DROPDOWN_TEXT_MAX_LENGTH = 15
|
||||||
# Max characters for filenames, after which they get truncated
|
# Max characters for filenames, after which they get truncated
|
||||||
FILENAME_TRUNCATION_LENGTH = 50
|
FILENAME_TRUNCATION_LENGTH = 50
|
||||||
|
# Max characters for names of exported files and folders, after which they get
|
||||||
|
# truncated
|
||||||
|
EXPORTED_FILENAME_TRUNCATION_LENGTH = 20
|
||||||
|
|
||||||
USER_INITIALS_MAX_LENGTH = 4
|
USER_INITIALS_MAX_LENGTH = 4
|
||||||
# Password 'key stretching' factor
|
# Password 'key stretching' factor
|
||||||
|
|
|
@ -459,11 +459,13 @@ en:
|
||||||
no_samples: "No samples"
|
no_samples: "No samples"
|
||||||
module_repository:
|
module_repository:
|
||||||
name: "%{repository} of task %{my_module}"
|
name: "%{repository} of task %{my_module}"
|
||||||
|
table_name: "[ %{name} ]"
|
||||||
no_items: "No items"
|
no_items: "No items"
|
||||||
result_asset:
|
result_asset:
|
||||||
file_name: "[ %{file} ]"
|
file_name: "[ %{file} ]"
|
||||||
user_time: "Uploaded by %{user} on %{timestamp}."
|
user_time: "Uploaded by %{user} on %{timestamp}."
|
||||||
result_table:
|
result_table:
|
||||||
|
table_name: "[ %{name} ]"
|
||||||
user_time: "Created by %{user} on %{timestamp}."
|
user_time: "Created by %{user} on %{timestamp}."
|
||||||
result_text:
|
result_text:
|
||||||
user_time: "Created by %{user} on %{timestamp}."
|
user_time: "Created by %{user} on %{timestamp}."
|
||||||
|
|
|
@ -24,7 +24,7 @@
|
||||||
"@fortawesome/fontawesome-free": "^5.2.0",
|
"@fortawesome/fontawesome-free": "^5.2.0",
|
||||||
"babel-eslint": "^8.2.6",
|
"babel-eslint": "^8.2.6",
|
||||||
"babel-plugin-transform-react-jsx-source": "^6.22.0",
|
"babel-plugin-transform-react-jsx-source": "^6.22.0",
|
||||||
"eslint": "^5.2.0",
|
"eslint": "^5.3.0",
|
||||||
"eslint-config-airbnb": "^15.1.0",
|
"eslint-config-airbnb": "^15.1.0",
|
||||||
"eslint-config-airbnb-base": "^13.0.0",
|
"eslint-config-airbnb-base": "^13.0.0",
|
||||||
"eslint-config-google": "^0.9.1",
|
"eslint-config-google": "^0.9.1",
|
||||||
|
|
Loading…
Reference in a new issue