Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app/models/exports/pdf/common/common.rb
Original file line number Diff line number Diff line change
Expand Up @@ -428,9 +428,9 @@ def make_link_href_cell(href, caption)
"<color rgb='#{styles.link_color}'>#{make_link_href(href, caption)}</color>"
end

def get_id_column_cell(work_package, value)
def get_id_column_cell(work_package)
href = url_helpers.work_package_url(work_package)
make_link_href_cell(href, value)
make_link_href_cell(href, work_package.display_id)
end

def get_subject_column_cell(work_package, value)
Expand Down Expand Up @@ -469,7 +469,7 @@ def get_cf_link_cell(custom_url)
def get_value_cell_by_column(work_package, column_name, format_subject)
value = get_column_value(work_package, column_name)
return get_cf_link_cell(value) if value.is_a?(::Exports::Formatters::LinkFormatter)
return get_id_column_cell(work_package, value) if column_name == :id
return get_id_column_cell(work_package) if column_name == :id
return get_subject_column_cell(work_package, value) if format_subject && column_name == :subject

escape_tags(value)
Expand Down
36 changes: 3 additions & 33 deletions app/models/exports/pdf/common/markdown.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class MD2PDFExport
include MarkdownToPDF::Core
include MarkdownToPDF::Parser
include Exports::PDF::Common::Common
include Exports::PDF::Common::WorkPackageMentions

def initialize(styling_yml, pdf, hyphenation_language)
@styles = MarkdownToPDF::Styles.new(styling_yml)
Expand Down Expand Up @@ -94,45 +95,14 @@ def handle_wp_mention_html_tag(tag, node, opts)
wp_mention_macro(tag.attr("data-text") || "", tag.attr("data-id") || "", opts)
end

def expand_wp_mention(work_package, content)
detail_level = content.count("#")
return content if detail_level == 1

# ##1234: {Type} #{ID}: {Subject}
content = "#{work_package.type} ##{work_package.id}: #{work_package.subject}"
return content if detail_level == 2

# ###1234: {Status} {Type} #{ID}: {Subject} ({Start Date} - {End Date})
"#{work_package.status.name} #{content}#{work_package_dates(work_package)}"
end

def wp_mention_macro(content, id, opts)
id = id[/\d+/]
return [text_hash(content, opts)] if id.blank?

work_package = WorkPackage.find_by(id: id)
work_package = WorkPackage.find_by_display_id(id)
return [text_hash(content, opts)] unless work_package&.visible?

content = expand_wp_mention(work_package, content)
[text_hash(content, opts.merge({ link: url_helpers.work_package_url(id) }))]
end

def work_package_dates(work_package)
return "" if work_package.start_date.blank? && work_package.due_date.blank?

if work_package.due_date.present? && work_package.start_date == work_package.due_date
return " (#{format_date(work_package.due_date)})"
end

work_package_date_range(work_package)
end

def work_package_date_range(work_package)
content = [
work_package.start_date.present? ? format_date(work_package.start_date) : I18n.t("label_no_start_date"),
work_package.due_date.present? ? format_date(work_package.due_date) : I18n.t("label_no_due_date")
].join(" - ")
" (#{content})"
[text_hash(content, opts.merge({ link: url_helpers.work_package_url(work_package) }))]
end

def handle_mention_html_tag(tag, node, opts)
Expand Down
63 changes: 63 additions & 0 deletions app/models/exports/pdf/common/work_package_mentions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 Exports::PDF::Common::WorkPackageMentions
include Redmine::I18n

def expand_wp_mention(work_package, content)
detail_level = content.count("#")
return work_package.formatted_id if detail_level == 1

# ##: {Type} {formatted_id}: {Subject}
content = "#{work_package.type} #{work_package.formatted_id}: #{work_package.subject}"
return content if detail_level == 2

# ###: {Status} {Type} {formatted_id}: {Subject} ({Start Date} - {End Date})
"#{work_package.status.name} #{content}#{work_package_dates(work_package)}"
end

def work_package_dates(work_package)
return "" if work_package.start_date.blank? && work_package.due_date.blank?

if work_package.due_date.present? && work_package.start_date == work_package.due_date
return " (#{format_date(work_package.due_date)})"
end

work_package_date_range(work_package)
end

def work_package_date_range(work_package)
content = [
work_package.start_date.present? ? format_date(work_package.start_date) : I18n.t("label_no_start_date"),
work_package.due_date.present? ? format_date(work_package.due_date) : I18n.t("label_no_due_date")
].join(" - ")
" (#{content})"
end
end
2 changes: 1 addition & 1 deletion app/models/exports/pdf/components/gantt/gantt_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,7 @@ def build_row_text_lines_wp_info(entry, left, right, top)
# @param [WorkPackage] work_package
# @return [String]
def work_package_info_line(work_package)
"#{work_package.type} ##{work_package.id} • #{work_package.status} • #{work_package_info_line_date work_package}"
"#{work_package.type} #{work_package.formatted_id} • #{work_package.status} • #{work_package_info_line_date work_package}"
end

def work_package_info_line_date(work_package)
Expand Down
67 changes: 43 additions & 24 deletions app/models/work_package/exports/macros/links.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,34 +31,51 @@
module WorkPackage::Exports
module Macros
class WorkPackagesLinkHandler < OpenProject::TextFormatting::Matchers::LinkHandlers::WorkPackages
# PDF export currently only renders canonical numeric `#N` references.
# Semantic `#PROJ-1` shapes and leading-zero numerics like `#0123`
# match the parent regex (because `Macros::Links` subclasses
# `ResourceLinksMatcher`) but are rejected here so they fall through
# to literal text rather than emitting a broken `<mention data-id="0">`
# (since `"PROJ-1".to_i == 0`).
#
# Semantic-id support in PDF export is tracked separately in
# https://community.openproject.org/wp/74366.
# PDF rendering walks Markly nodes via `app/models/exports/pdf/common/macro.rb`,
# not through `PatternMatcherFilter`'s preload pipeline, so the parent's
# cache-driven `call` would miss every reference. Numeric references
# render directly from the matched id (no DB hit); semantic references
# resolve via `find_by_display_id` so `data-id` carries the user-facing
# identifier the downstream PDF renderer consumes through the same
# finder.
def applicable?
%w(# ## ###).include?(matcher.sep) &&
matcher.prefix.blank? &&
WorkPackage::SemanticIdentifier.numeric_id?(matcher.identifier)
return false unless %w(# ## ###).include?(matcher.sep) && matcher.prefix.blank?

if WorkPackage::SemanticIdentifier.numeric_id?(matcher.identifier)
true
elsif WorkPackage::SemanticIdentifier.semantic_id?(matcher.identifier)
# Semantic shape only links in semantic mode; classic instances
# fall through to literal text. Mirrors the in-app handler in
# `lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb`.
Setting::WorkPackageIdentifier.semantic_mode_active?
else
false
end
end

# PDF rendering walks Markly nodes via `app/models/exports/pdf/common/macro.rb`,
# not through `PatternMatcherFilter`'s preload pipeline, so the parent's
# cache-driven `call` would miss every reference. Render the legacy
# numeric mention directly from the matched id.
def call
render_link(matcher.identifier.to_i, matcher)
if WorkPackage::SemanticIdentifier.semantic_id?(matcher.identifier)
wp = WorkPackage.find_by_display_id(matcher.identifier)
# Cache miss → return nil so the matcher emits the literal text
# rather than a mention pointing at a non-existent identifier.
return nil unless wp

render_link(wp.display_id, matcher)
else
render_link(matcher.identifier.to_i.to_s, matcher)
end
end

def render_link(wp_id, matcher)
link = "#{matcher.sep}#{wp_id}"
"<mention class=\"mention\" data-id=\"#{wp_id}\" data-type=\"work_package\" data-text=\"#{link}\">#{
link
}</mention>"
def render_link(data_id, matcher)
# `data_id` is regex-constrained at the matcher layer (numeric `\d+`
# or semantic `[A-Z][A-Z0-9_]*-\d+` per `ID_ROUTE_CONSTRAINT`) and
# for semantic input is sourced from `wp.display_id`. Escape both
# interpolated values so a future widening of the constraint, or a
# caller that bypasses the matcher, cannot regress into HTML
# attribute injection.
escaped_id = ERB::Util.html_escape(data_id)
link = "#{matcher.sep}#{escaped_id}"
%(<mention class="mention" data-id="#{escaped_id}" data-type="work_package" data-text="#{link}">#{link}</mention>)
end
end

Expand All @@ -67,9 +84,11 @@ def self.link_handlers
[WorkPackagesLinkHandler]
end

# Faster inclusion check before the full regex is being applied
# Faster inclusion check before the full regex is being applied.
# Matches `#1`, `##42`, `#PROJ-7` openings — semantic-only bodies
# must reach the regex too.
def self.applicable?(content)
/#\d/.match(content)
/#[A-Z\d]/.match(content)
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion app/models/work_package/pdf_export/document_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def footer_title
def title
# <project>_<type>_<ID>_<subject><YYYY-MM-DD>_<HH-MM>.pdf
build_pdf_filename([work_package.project, work_package.type,
"##{work_package.id}", work_package.subject].join("_"))
work_package.display_id, work_package.subject].join("_"))
end

def with_images?
Expand Down
32 changes: 28 additions & 4 deletions app/models/work_package/pdf_export/generator/generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class MD2PDFGenerator
include MarkdownToPDF::Core
include MarkdownToPDF::Parser
include MarkdownToPDF::StyleSchema
include Exports::PDF::Common::WorkPackageMentions

def initialize(styling_yml)
symbol_yml = symbolize(styling_yml)
Expand Down Expand Up @@ -94,17 +95,40 @@ def hyphenate(text)
@hyphens.hyphenate(text)
end

def handle_wp_mention_html_tag(tag, node, opts)
# <mention class="mention" data-id="185" data-type="work_package" data-text="#185">#185</mention>
# <mention class="mention" data-id="185" data-type="work_package" data-text="##185">##185</mention>
# <mention class="mention" data-id="185" data-type="work_package" data-text="###185">###185</mention>
next_node = node&.next
if next_node && next_node.type == :text && next_node.respond_to?(:string_content)
next_node.string_content = ""
end
render_wp_mention(tag.attr("data-text") || "", tag.attr("data-id") || "", opts)
end

def render_wp_mention(content, id, opts)
return [text_hash(content, opts)] if id.blank?

work_package = WorkPackage.find_by_display_id(id)
return [text_hash(content, opts)] unless work_package&.visible?

[text_hash(expand_wp_mention(work_package, content), opts)]
end

def handle_mention_html_tag(tag, node, opts)
if tag.text.blank?
# <mention class="mention" data-id="46012" data-type="work_package" data-text="#46012"></mention>
if tag.attr("data-type") == "work_package"
handle_wp_mention_html_tag(tag, node, opts)
elsif tag.text.blank?
# <mention class="mention" data-id="3" data-type="user" data-text="@Some User">
text = tag.attr("data-text")
if text.present? && !node.next.respond_to?(:string_content) && node.next.string_content != text
return [text_hash(text, opts)]
end
[]
else
# <mention class="mention" data-id="3" data-type="user" data-text="@Some User">@Some User</mention>
[]
end
# <mention class="mention" data-id="3" data-type="user" data-text="@Some User">@Some User</mention>
[]
end

def handle_unknown_inline_html_tag(tag, node, opts)
Expand Down
4 changes: 2 additions & 2 deletions app/models/work_package/pdf_export/work_package_to_pdf.rb
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ def wp_title_formatted_text(work_package, style)
end

def heading
"#{work_package.type} ##{work_package.id} - #{work_package.subject}"
"#{work_package.type} #{work_package.formatted_id} - #{work_package.subject}"
end

def footer_title
Expand All @@ -118,7 +118,7 @@ def footer_title
def title
# <project>_<type>_<ID>_<subject><YYYY-MM-DD>_<HH-MM>.pdf
build_pdf_filename([work_package.project, work_package.type,
"##{work_package.id}", work_package.subject].join("_"))
work_package.display_id, work_package.subject].join("_"))
end

def write_description!(work_package)
Expand Down
Loading
Loading