Skip to content

Commit

Permalink
Merge pull request #15702 from opf/feature/54377-embedded-work-packag…
Browse files Browse the repository at this point in the history
…e-attributes-in-pdf-export

[#54377] Embedded work package attributes in PDF export
  • Loading branch information
as-op authored Jun 5, 2024
2 parents b842f9f + 33afc82 commit e102faf
Show file tree
Hide file tree
Showing 13 changed files with 652 additions and 39 deletions.
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,11 @@ gem "structured_warnings", "~> 0.4.0"
# don't require by default, instead load on-demand when actually configured
gem "airbrake", "~> 13.0.0", require: false

gem "markly", "~> 0.10" # another markdown parser like commonmarker, but with AST support used in PDF export
gem "md_to_pdf", git: "https://github.com/opf/md-to-pdf", ref: "8f14736a88ad0064d2a97be108fe7061ffbcee91"
gem "prawn", "~> 2.4"
gem "ttfunk", "~> 1.7.0" # remove after https://github.com/prawnpdf/prawn/issues/1346 resolved.

# prawn implicitly depends on matrix gem no longer in ruby core with 3.1
gem "matrix", "~> 0.4.2"

Expand Down
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,7 @@ DEPENDENCIES
lograge (~> 0.14.0)
lookbook (~> 2.3.0)
mail (= 2.8.1)
markly (~> 0.10)
matrix (~> 0.4.2)
md_to_pdf!
meta-tags (~> 2.21.0)
Expand Down
6 changes: 3 additions & 3 deletions app/models/exports/formatters/custom_field.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
module Exports
module Formatters
class CustomField < Default
def self.apply?(attribute, _export_format)
attribute.start_with?("cf_")
def self.apply?(attribute, export_format)
export_format != :pdf && attribute.start_with?("cf_")
end

##
# Takes a WorkPackage and an attribute and returns the value to be exported.
# Takes a WorkPackage or Project and an attribute and returns the value to be exported.
def retrieve_value(object)
custom_field = find_custom_field(object)
return "" if custom_field.nil?
Expand Down
27 changes: 27 additions & 0 deletions app/models/exports/formatters/custom_field_pdf.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module Exports
module Formatters
class CustomFieldPdf < CustomField
def self.apply?(attribute, export_format)
export_format == :pdf && attribute.start_with?("cf_")
end

##
# Print the value meant for export.
#
# - For boolean values, use the Yes/No formatting for the PDF
# treat nil as false
# - For long text values, output the plain value
def format_for_export(object, custom_field)
case custom_field.field_format
when "bool"
value = object.typed_custom_value_for(custom_field)
value ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No)
when "text"
object.typed_custom_value_for(custom_field)
else
object.formatted_custom_value_for(custom_field)
end
end
end
end
end
42 changes: 42 additions & 0 deletions app/models/projects/exports/formatters/active.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module Projects::Exports
module Formatters
class Active < ::Exports::Formatters::Default
def self.apply?(attribute, export_format)
export_format == :pdf && %i[active].include?(attribute.to_sym)
end

##
# Takes a project and returns yes/no depending on the active attribute
def format(project, **)
project.active? ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No)
end
end
end
end
42 changes: 42 additions & 0 deletions app/models/projects/exports/formatters/public.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module Projects::Exports
module Formatters
class Public < ::Exports::Formatters::Default
def self.apply?(attribute, export_format)
export_format == :pdf && %i[public].include?(attribute.to_sym)
end

##
# Takes a project and returns yes/no depending on the public attribute
def format(project, **)
project.public? ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No)
end
end
end
end
172 changes: 172 additions & 0 deletions app/models/work_package/exports/macros/attributes.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module WorkPackage::Exports
module Macros
# OpenProject attribute macros syntax
# Examples:
# workPackageLabel:subject # Outputs work package label attribute "Subject"
# workPackageLabel:1234:subject # Outputs work package label attribute "Subject"

# workPackageValue:subject # Outputs the subject of the current work package
# workPackageValue:1234:subject # Outputs the subject of #1234
# workPackageValue:"custom field name" # Outputs the custom field value of the current work package
# workPackageValue:1234:"custom field name" # Outputs the custom field value of #1234
#
# projectLabel:active # Outputs current project label attribute "active"
# projectLabel:1234:active # Outputs project label attribute "active"
# projectLabel:my-project-identifier:active # Outputs project label attribute "active"

# projectValue:active # Outputs current project value for "active"
# projectValue:1234:active # Outputs project with id 1234 value for "active"
# projectValue:my-project-identifier:active # Outputs project with identifier my-project-identifier value for "active"
class Attributes < OpenProject::TextFormatting::Matchers::RegexMatcher
DISABLED_PROJECT_RICH_TEXT_FIELDS = %i[description status_explanation status_description].freeze
DISABLED_WORK_PACKAGE_RICH_TEXT_FIELDS = %i[description].freeze

def self.regexp
%r{
(\w+)(Label|Value) # The model type we try to reference
(?::(?:([^"\s]+)|"([^"]+)"))? # Optional: An ID or subject reference
(?::([^"\s.]+|"([^".]+)")) # The attribute name we're trying to reference
}x
end

##
# Faster inclusion check before the regex is being applied
def self.applicable?(content)
content.include?("Label:") || content.include?("Value:")
end

def self.process_match(match, _matched_string, context)
context => { user:, work_package: }
type = match[2].downcase
model_s = match[1]
id = match[4] || match[3]
attribute = match[6] || match[5]
resolve_match(type, model_s, id, attribute, work_package, user)
end

def self.resolve_match(type, model_s, id, attribute, work_package, user)
if model_s == "workPackage"
resolve_work_package_match(id || work_package.id, type, attribute, user)
elsif model_s == "project"
resolve_project_match(id || work_package.project.id, type, attribute, user)
else
msg_macro_error I18n.t('export.macro.model_not_found', model: model_s)
end
end

def self.msg_macro_error(message)
msg_inline I18n.t('export.macro.error', message:)
end

def self.msg_macro_error_rich_text
msg_inline I18n.t('export.macro.rich_text_unsupported')
end

def self.msg_inline(message)
"[#{message}]"
end

def self.resolve_label_work_package(attribute)
resolve_label(WorkPackage, attribute)
end

def self.resolve_label_project(attribute)
resolve_label(Project, attribute)
end

def self.resolve_label(model, attribute)
model.human_attribute_name(
::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: model.new)
)
end

def self.resolve_work_package_match(id, type, attribute, user)
return resolve_label_work_package(attribute) if type == "label"
return msg_macro_error(I18n.t('export.macro.model_not_found', model: type)) unless type == "value"

work_package = WorkPackage.visible(user).find_by(id:)
if work_package.nil?
return msg_macro_error(I18n.t('export.macro.resource_not_found', resource: "#{WorkPackage.name} #{id}"))
end

resolve_value_work_package(work_package, attribute)
end

def self.resolve_project_match(id, type, attribute, user)
return resolve_label_project(attribute) if type == "label"
return msg_macro_error(I18n.t("export.macro.model_not_found", model: type)) unless type == "value"

project = Project.visible(user).find_by(id:)
project = Project.visible(user).find_by(identifier: id) if project.nil?
if project.nil?
return msg_macro_error(I18n.t("export.macro.resource_not_found", resource: "#{Project.name} #{id}"))
end

resolve_value_project(project, attribute)
end

def self.escape_tags(value)
# only disable html tags, but do not replace html entities
value.to_s.gsub("<", "&lt;").gsub(">", "&gt;")
end

def self.resolve_value_project(project, attribute)
resolve_value(project, attribute, DISABLED_PROJECT_RICH_TEXT_FIELDS)
end

def self.resolve_value_work_package(work_package, attribute)
resolve_value(work_package, attribute, DISABLED_WORK_PACKAGE_RICH_TEXT_FIELDS)
end

def self.resolve_value(obj, attribute, disabled_rich_text_fields)
cf = obj.available_custom_fields.find { |pcf| pcf.name == attribute }

return msg_macro_error_rich_text if cf&.formattable?

ar_name = if cf.nil?
::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: obj)
else
"cf_#{cf.id}"
end
return msg_macro_error_rich_text if disabled_rich_text_fields.include?(ar_name.to_sym)

format_attribute_value(ar_name, obj.class, obj)
end

def self.format_attribute_value(ar_name, model, obj)
formatter = Exports::Register.formatter_for(model, ar_name, :pdf)
value = formatter.format(obj)
# important NOT to return empty string as this could change meaning of markdown
# e.g. **to_be_replaced** could be rendered as **** (horizontal line and a *)
value.blank? ? " " : escape_tags(value)
end
end
end
end
48 changes: 47 additions & 1 deletion app/models/work_package/pdf_export/markdown_field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

module WorkPackage::PDFExport::MarkdownField
include WorkPackage::PDFExport::Markdown
PREFORMATTED_BLOCKS = %w(pre code).freeze

def write_markdown_field!(work_package, markdown, label)
return if markdown.blank?
Expand All @@ -37,7 +38,52 @@ def write_markdown_field!(work_package, markdown, label)
pdf.formatted_text([styles.wp_markdown_label.merge({ text: label })])
end
with_margin(styles.wp_markdown_margins) do
write_markdown! work_package, markdown
write_markdown! work_package, apply_markdown_field_macros(markdown, work_package)
end
end

private

def apply_markdown_field_macros(markdown, work_package)
apply_macros(markdown, work_package, WorkPackage::Exports::Macros::Attributes)
end

def apply_macros(markdown, work_package, formatter)
return markdown unless formatter.applicable?(markdown)

document = Markly.parse(markdown)
document.walk do |node|
if node.type == :html
node.string_content = apply_macro_html(node.string_content, work_package, formatter) || node.string_content
elsif node.type == :text
node.string_content = apply_macro_text(node.string_content, work_package, formatter) || node.string_content
end
end
document.to_markdown
end

def apply_macro_text(text, work_package, formatter)
return text unless formatter.applicable?(text)

text.gsub!(formatter.regexp) do |matched_string|
matchdata = Regexp.last_match
formatter.process_match(matchdata, matched_string, { user: User.current, work_package: })
end
end

def apply_macro_html(html, work_package, formatter)
return html unless formatter.applicable?(html)

doc = Nokogiri::HTML.fragment(html)
apply_macro_html_node(doc, work_package, formatter)
doc.to_html
end

def apply_macro_html_node(node, work_package, formatter)
if node.text?
node.content = apply_macro_text(node.content, work_package, formatter)
elsif PREFORMATTED_BLOCKS.exclude?(node.name)
node.children.each { |child| apply_macro_html_node(child, work_package, formatter) }
end
end
end
Loading

0 comments on commit e102faf

Please sign in to comment.