[#65994] PDF export of project list

https://community.openproject.org/work_packages/65994
This commit is contained in:
as-op
2025-07-31 14:29:07 +02:00
parent a8c1ec7d58
commit 5b5caaa186
20 changed files with 3336 additions and 9 deletions
@@ -10,6 +10,8 @@
<% helpers.supported_export_formats.each do |key| %>
<li class="op-export-options--option">
<%= link_to projects_path(format: key, **helpers.projects_query_params.except(:page, :per_page)),
"data-controller": "job-dialog",
"data-job-dialog-close-dialog-id-value": MODAL_ID,
class: "op-export-options--option-link" do %>
<%= helpers.op_icon("icon-big icon-export-#{key}") %>
<span class="op-export-options--option-label"><%= t("export.format.#{key}") %></span>
+1 -1
View File
@@ -128,7 +128,7 @@ module Exports::PDF::Common::Common
previous_color = pdf.stroke_color
previous_line_width = pdf.line_width
@pdf.stroke do
pdf.stroke_color = color
pdf.stroke_color = color if color
pdf.line_width = height
pdf.horizontal_line left, right, at: top
end
@@ -1,5 +1,3 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
@@ -31,7 +29,7 @@ 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)
export_format == :pdf && attribute.to_sym == :active
end
##
@@ -0,0 +1,44 @@
# 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 Projects::Exports
module Formatters
class Favored < ::Exports::Formatters::Default
def self.apply?(attribute, export_format)
export_format == :pdf && attribute.to_sym == :favored
end
##
# Takes a project and returns yes/no depending on the favored attribute
def format(project, **)
project.favored_by?(User.current) ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No)
end
end
end
end
@@ -31,7 +31,7 @@ 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)
export_format == :pdf && attribute.to_sym == :public
end
##
@@ -0,0 +1,46 @@
# 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 Projects::Exports
module Formatters
class RequiredDiskSpace < ::Exports::Formatters::Default
def self.apply?(attribute, export_format)
export_format == :pdf && attribute.to_sym == :required_disk_space
end
##
# Takes a project and returns the formatted value if the required disk space is greater than 0.
def format(project, **)
return "" unless project.required_disk_space.to_i > 0
number_to_human_size(project.required_disk_space, precision: 2)
end
end
end
end
+141
View File
@@ -0,0 +1,141 @@
# 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 Projects::Exports
class PDF < QueryExporter
include Exports::PDF::Common::Common
include Exports::PDF::Common::Logo
include Exports::PDF::Common::Attachments
include Exports::PDF::Common::Markdown
include Exports::PDF::Components::Page
include Exports::PDF::Components::WpTable
include Exports::PDF::Components::Cover
include Projects::Exports::PDFExport::TableOfContent
include Projects::Exports::PDFExport::Report
include Projects::Exports::PDFExport::InfoMap
include Projects::Exports::PDFExport::Styles
attr_accessor :pdf
def initialize(object, options = {})
super
setup_page!
end
def setup_page!
self.pdf = get_pdf
configure_page_size!(:portrait)
pdf.title = heading
end
def export!
file = render_pdf(all_projects)
success(file)
rescue StandardError => e
Rails.logger.error "Failed to generate PDF export: #{e.message}:\n#{e.backtrace.join("\n")}"
error(I18n.t(:error_pdf_failed_to_export, error: e.message))
end
def title
build_pdf_filename([heading].join("_"))
end
def heading
query.name || I18n.t(:label_project_plural)
end
def footer_title
heading
end
def cover_page_heading
heading
end
def cover_page_dates
nil
end
def cover_page_subheading
User.current&.name
end
def cover_page_title
@cover_page_title ||= Setting.app_title
end
def render_pdf(projects, filename: "pdf_export")
@page_count = 0
@id_project_meta_map, flat_list = build_meta_infos_map(projects)
file = render_projects_report_pdf(flat_list, filename)
if wants_total_page_nrs?
@total_page_nr = @page_count
@page_count = 0
setup_page! # clear current pdf
file = render_projects_report_pdf(flat_list, filename)
end
file
end
def wants_total_page_nrs?
true
end
def with_cover?
true
end
def render_projects_report_pdf(flat_list, filename)
render_projects_report(flat_list)
file = Tempfile.new(filename)
pdf.render_file(file.path)
@page_count += pdf.page_count
delete_all_resized_images
file.close
file
end
def write_after_pages!
write_headers!
write_footers!
end
def render_projects_report(flat_list)
write_cover_page! if with_cover?
if flat_list.size > 1
render_toc(flat_list, @id_project_meta_map)
elsif !flat_list.empty?
@id_project_meta_map[flat_list[0].id][:level_path] = []
end
render_report(flat_list, @id_project_meta_map)
write_after_pages!
end
end
end
@@ -0,0 +1,80 @@
# 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 Projects::Exports::PDFExport
module InfoMap
def build_flat_meta_infos_map(projects)
infos_map = {}
projects.each_with_index do |project, index|
infos_map[project.id] = { level_path: [index + 1], level: 0, children: [], project: }
end
[infos_map, projects.to_a]
end
def init_meta_infos_map_nodes(projects)
infos_map = {}
projects.each do |project|
infos_map[project.id] = { level_path: [], level: 0, children: [], project: }
end
infos_map
end
def link_meta_infos_map_nodes(infos_map, projects)
projects.reject { |wp| wp.parent_id.nil? }.each do |project|
parent = infos_map[project.parent_id]
infos_map[project.id][:parent] = parent
parent[:children].push(infos_map[project.id]) if parent
end
infos_map
end
def build_meta_infos_map(projects)
# build a quick access map for the hierarchy tree
infos_map = init_meta_infos_map_nodes projects
# connect parent and children (only wp available in the query)
infos_map = link_meta_infos_map_nodes infos_map, projects
# recursive travers creating level index path e.g. [1, 2, 1] from root nodes
root_nodes = infos_map.values.select { |node| node[:parent].nil? }
flat_list = []
fill_meta_infos_map_nodes({ children: root_nodes }, [], flat_list)
[infos_map, flat_list]
end
def fill_meta_infos_map_nodes(node, level_path, flat_list)
node[:level_path] = level_path
flat_list.push(node[:project]) unless node[:project].nil?
index = 1
node[:children].each do |sub|
fill_meta_infos_map_nodes(sub, level_path + [index], flat_list)
index += 1
end
end
end
end
@@ -0,0 +1,309 @@
# 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 Projects::Exports::PDFExport
module Report
def render_report(projects, info_map)
projects.each do |project|
write_optional_page_break
write_project_detail(project, info_map[project.id])
end
end
def write_project_detail(project, info_map_entry)
info_map_entry[:page_number] = current_page_nr
with_margin(styles.project_margins) do
write_project_title(project, info_map_entry[:level_path])
write_project_detail_content(project)
write_project_work_packages(project)
end
end
def write_project_title(project, level_path)
text_style = styles.project_title
with_margin(styles.project_title_margins) do
link_target_at_current_y(project.id)
level_string_width = write_project_level(level_path, text_style)
pdf.indent(level_string_width) do
pdf.formatted_text([text_style.merge({ text: project.name, link: url_helpers.project_url(project) })])
end
end
end
def write_project_level(level_path, text_style)
return 0 if level_path.empty?
level_string = "#{level_path.join('.')}. "
level_string_width = measure_text_width(level_string, text_style)
pdf.float { pdf.formatted_text([text_style.merge({ text: level_string })]) }
level_string_width
end
def selects
@selects = query
.selects
.reject { |s| s.is_a?(Queries::Selects::NotExistingSelect) }
end
def write_project_detail_content(project)
return if selects.empty?
entries = []
selects.each do |select|
entries = process_select(project, select, entries)
end
write_table_entries(entries) unless entries.empty?
end
def process_select(project, select, entries)
if custom_field_select?(select)
process_custom_field_select(project, select, entries)
elsif project_phase_select?(select)
process_project_phase_select(project, select, entries)
elsif can_view_attribute?(project, select.attribute)
process_attribute_select(project, select, entries)
else
entries
end
end
def process_custom_field_select(project, select, entries)
return entries unless custom_field_active_in_project?(project, select.custom_field)
if select.custom_field.formattable?
write_table_entries(entries) unless entries.empty?
write_formattable_custom_field(project, select.custom_field)
[]
else
entry = table_entry(project, "cf_#{select.custom_field.id}", select.caption)
entries.push(entry) if entry
entries
end
end
def process_project_phase_select(project, select, entries)
entry = user_can_view_project_phases?(project) ? table_entry_project_phase(project, select) : nil
entries.push(entry) if entry
entries
end
def process_attribute_select(project, select, entries)
if attribute_formattable?(select.attribute)
write_table_entries(entries) unless entries.empty?
write_formattable_attribute(project, select.attribute, select.caption)
[]
else
entry = table_entry(project, select.attribute, select.caption)
entries.push(entry) if entry
entries
end
end
def project_phase_select?(select)
select.is_a?(::Queries::Projects::Selects::ProjectPhase)
end
def table_entry_project_phase(project, select)
phase = project.phases.active.find_by(definition: select.project_phase_definition)
return nil if phase.nil?
[
{ content: select.caption }.merge(styles.project_attributes_table_label_cell),
format_phase_value(phase)
]
end
def format_phase_value(phase)
start = if phase.start_date.present?
format_date(phase.start_date)
else
I18n.t("js.label_no_start_date")
end
finish = if phase.finish_date.present?
format_date(phase.finish_date)
else
I18n.t("js.label_no_due_date")
end
"#{start} - #{finish}"
end
def table_entry(project, value_name, caption)
value = format_attribute(project, value_name, :pdf)
return nil if hide_empty_attributes? && value.blank?
value = make_link_href_cell(url_helpers.project_url(project), value) if value_name == :id
[
{ content: caption }.merge(styles.project_attributes_table_label_cell),
value || ""
]
end
def can_view_attribute?(_project, attribute)
return false if attribute.nil? || %i[name favored].include?(attribute)
true
end
def user_can_view_project_phases?(project)
User.current.allowed_in_project?(:view_project_phases, project) && project.phases.active.any?
end
def attribute_formattable?(attribute)
%i[description status_explanation].include? attribute
end
def custom_field_select?(select)
select.is_a?(::Queries::Projects::Selects::CustomField)
end
def custom_field_active_in_project?(project, custom_field)
custom_field.is_for_all? ||
project.project_custom_field_project_mappings.exists?(custom_field_id: custom_field.id)
end
def write_formattable_attribute(project, attribute, caption)
write_project_markdown project.try(attribute), caption
end
def write_formattable_custom_field(project, custom_field)
custom_field_value = project.custom_value_for(custom_field.id)
write_project_markdown custom_field_value.value, custom_field.name
end
def write_project_markdown(value, caption)
return if hide_empty_attributes? && value.blank?
write_markdown_label(caption)
value = Prawn::Text::NBSP if value.blank?
with_margin(styles.project_markdown_margins) do
write_markdown!(value, styles.project_markdown_styling_yml)
end
end
def write_markdown_label(caption)
with_margin(styles.project_markdown_label_margins) do
pdf.formatted_text([styles.project_markdown_label.merge({ text: caption })])
end
end
def write_table_entries(row_entries)
return if row_entries.empty?
rows = if attributes_table_4_column?
0.step(row_entries.length - 1, 2).map do |i|
row_entries[i] + (row_entries[i + 1] || ["", ""])
end
else
row_entries
end
pdf.table(
rows,
column_widths: attributes_table_column_widths,
cell_style: styles.project_attributes_table_cell.merge({ inline_format: true })
)
end
def attributes_table_4_column?
true
end
def hide_empty_attributes?
true
end
def attributes_table_column_widths
widths = if attributes_table_4_column?
# label | value | label | value
[1.5, 2.0, 1.5, 2.0]
else
# label | value
[1.0, 3.0]
end
ratio = pdf.bounds.width / widths.sum
widths.map { |w| w * ratio }
end
def write_project_work_packages(project)
return if work_package_tables.empty?
with_margin(styles.wp_tables_margins) do
write_project_work_packages_tables(project)
end
end
def write_project_work_packages_tables(project)
work_package_tables.each do |table|
type_id = table[:type_id]
column_names = table[:column_names]
sort_criteria = table[:sort_criteria] || []
caption = table[:caption]
query = build_work_packages_query(project, type_id, column_names, sort_criteria)
write_project_work_packages_table(query, caption) if query
end
end
def work_package_tables
[]
end
def build_work_packages_query(project, type_id, column_names, sort_criteria = [])
return nil unless type_id
query = Query.new(project:)
query.filters.clear
query.include_subprojects = false
query.column_names = column_names
query.sort_criteria = sort_criteria
query.add_filter("type_id", "=", [type_id])
query
end
def get_column_value_cell(work_package, column_name)
value = get_value_cell_by_column(work_package, column_name, false)
value = make_link_href(url_helpers.work_package_url(work_package), value) if column_name == :subject
value
end
def write_project_work_packages_table(query, caption)
return unless query
work_packages = query.results.work_packages
return unless work_packages.any?
write_optional_page_break
write_markdown_label(caption)
write_work_packages_table!(work_packages, query)
end
end
end
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,321 @@
page:
page_size: "A4"
margin_top: 80
margin_bottom: 60
margin_left: 40
margin_right: 40
page_break_threshold: 200
link_color: "175A8E"
page_header:
offset: 20
size: 8
page_footer:
offset: -30
size: 8
spacing: 6
page_logo:
height: 20
align: "right"
offset: 10
page_heading:
size: 14
styles: [ "bold" ]
margin_bottom: 10
cover:
header:
logo_height: 25
border:
color: 'd3dee3'
height: 1
offset: 6
footer:
size: 10
color: '414d5f'
offset: 30
hero:
padding_right: 144
padding_top: 120
title:
max_height: 30
spacing: 10
font: 'SpaceMono'
color: '414d5f'
size: 10
heading:
spacing: 14
color: '414d5f'
styles:
- bold
size: 16
dates:
spacing: 4
max_height: 16
color: '414d5f'
size: 10
styles:
- bold
subheading:
max_height: 30
color: '414d5f'
size: 10
toc:
title:
size: 16
color: "000000"
margin_bottom: 4
subject_indent: 4
indent_mode: "stairs"
margin_top: 10
margin_bottom: 20
item:
size: 10
color: "000000"
margin_bottom: 12
item_level_1:
size: 11
styles: [ "bold" ]
margin_top: 6
margin_bottom: 6
item_level_2:
size: 11
margin_top: 4
margin_bottom: 4
item_level_3:
size: 10
margin_top: 4
margin_bottom: 4
item_level_4:
size: 10
margin_top: 4
margin_bottom: 4
item_level_5:
size: 10
margin_top: 4
margin_bottom: 4
project:
margin_bottom: 20
title:
size: 12
color: "000000"
margin_bottom: 8
styles: [ "bold" ]
markdown_label:
size: 10
color: "000000"
styles: [ "bold" ]
margin_bottom: 3
markdown_margins:
margin_bottom: 8
markdown:
font:
size: 10
leading: 3
header:
size: 8
styles: [ "bold" ]
padding_top: 4
header_1:
size: 10
header_2:
size: 10
header_3:
size: 9
paragraph:
align: "left"
unordered_list:
spacing: 1
unordered_list_point:
spacing: 4
ordered_list:
spacing: 1
ordered_list_point:
spacing: 4
spanning: true
list_style_type: decimal
ordered_list_point_2:
list_style_type: lower-latin
ordered_list_point_3:
list_style_type: lower-roman
ordered_list_point_4:
list_style_type: upper-latin
ordered_list_point_5:
list_style_type: upper-roman
task_list:
spacing: 1
task_list_point:
spacing: 4
checked: "☑"
unchecked: "☐"
link:
color: "175A8E"
styles: [ ]
code:
color: "880000"
size: 9
font: "SpaceMono"
blockquote:
background_color: "f4f9ff"
size: 10
styles: [ "italic" ]
color: "0f3b66"
border_color: "b8d6f4"
border_width: 1
padding: 4
padding_left: 6
margin_top: 4
margin_bottom: 4
no_border_left: false
no_border_right: true
no_border_bottom: true
no_border_top: true
image:
align: "center"
margin_bottom: 4
caption:
size: 8
align: "center"
codeblock:
background_color: "F5F5F5"
color: "880000"
padding: 10
size: 8
margin_top: 10
margin_bottom: 10
font: "SpaceMono"
table:
auto_width: true
margin_top: 4
margin_bottom: 4
header:
size: 9
styles: [ "bold" ]
background_color: "F0F0F0"
cell:
size: 8
border_width: 0.25
padding: 5
html_table:
auto_width: true
margin_top: 4
margin_bottom: 4
header:
size: 9
styles: [ "bold" ]
border_width: 0.25
border_color: '000000'
no_border: true
cell:
size: 8
padding: 5
border_width: 0.25
border_color: '000000'
no_border: true
valign: middle
alerts:
NOTE:
border_color: '0969da'
alert_color: '0969da'
padding: '4mm'
size: 10
styles: [ ]
border_width: 2
no_border_right: true
no_border_left: false
no_border_bottom: true
no_border_top: true
TIP:
border_color: '1a7f37'
alert_color: '1a7f37'
padding: '4mm'
size: 10
styles: [ ]
border_width: 2
no_border_right: true
no_border_left: false
no_border_bottom: true
no_border_top: true
IMPORTANT:
border_color: '8250df'
alert_color: '8250df'
padding: '4mm'
size: 10
styles: [ ]
border_width: 2
no_border_right: true
no_border_left: false
no_border_bottom: true
no_border_top: true
WARNING:
border_color: 'bf8700'
alert_color: 'bf8700'
padding: '4mm'
size: 10
styles: [ ]
border_width: 2
no_border_right: true
no_border_left: false
no_border_bottom: true
no_border_top: true
CAUTION:
border_color: 'd1242f'
alert_color: 'd1242f'
size: 10
styles: [ ]
padding: '4mm'
border_width: 2
no_border_right: true
no_border_left: false
no_border_bottom: true
no_border_top: true
attributes_group:
size: 11
styles: [ "bold" ]
margin_top: 0
margin_bottom: 4
hr:
color: "1F2328"
height: 0.25
attributes_table:
cell:
size: 10
color: "000000"
padding_left: 0
padding_right: 5
padding_top: 0
padding_bottom: 8
border_color: "4B4B4B"
border_width: 0.25
no_border: true
cell_label:
styles: [ "bold" ]
size: 10
wp_table:
margins:
margin_top: 8
group_heading:
size: 11
styles: [ "bold" ]
margin_bottom: 10
table:
margin_bottom: 5
cell:
size: 9
color: "000000"
padding_left: 0
padding_right: 5
padding_top: 0
padding_bottom: 5
border_width: 0.25
no_border: true
cell_header:
size: 9
styles: [ "bold" ]
@@ -0,0 +1,133 @@
# 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 Projects::Exports::PDFExport::Styles
class PDFStyles
include MarkdownToPDF::Common
include MarkdownToPDF::StyleHelper
include Exports::PDF::Common::Styles
include Exports::PDF::Components::PageStyles
include Exports::PDF::Components::CoverStyles
include Exports::PDF::Components::WpTableStyles
include WorkPackage::PDFExport::Common::AttributesTableStyles
def project_title
resolve_font(@styles.dig(:project, :title))
end
def project_title_margins
resolve_margin(@styles.dig(:project, :title))
end
def project_margins
resolve_margin(@styles[:project])
end
def toc_title
resolve_font(@styles.dig(:toc, :title))
end
def toc_title_margins
resolve_margin(@styles.dig(:toc, :title))
end
def toc_max_depth
@styles.dig(:toc, :max_depth) || 4
end
def toc_margins
resolve_margin(@styles[:toc])
end
def toc_indent_mode
@styles.dig(:toc, :indent_mode)
end
def toc_item(level)
resolve_font(@styles.dig(:toc, :item)).merge(
resolve_font(@styles.dig(:toc, :"item_level_#{level}"))
)
end
def toc_item_subject_indent
resolve_pt(@styles.dig(:toc, :subject_indent), 4)
end
def toc_item_margins(level)
resolve_margin(@styles.dig(:toc, :item)).merge(
resolve_margin(@styles.dig(:toc, :"item_level_#{level}"))
)
end
def project_markdown_label
resolve_font(@styles.dig(:project, :markdown_label))
end
def project_markdown_label_margins
resolve_margin(@styles.dig(:project, :markdown_label))
end
def project_markdown_margins
resolve_margin(@styles.dig(:project, :markdown_margins))
end
def project_markdown_styling_yml
resolve_markdown_styling(@styles.dig(:project, :markdown) || {})
end
def project_attributes_table_margins
resolve_margin(@styles.dig(:project, :attributes_table))
end
def project_attributes_table_cell
resolve_table_cell(@styles.dig(:project, :attributes_table, :cell))
end
def project_attributes_table_label
resolve_font(@styles.dig(:project, :attributes_table, :cell_label))
end
def project_attributes_table_label_cell
project_attributes_table_cell.merge(
resolve_table_cell(@styles.dig(:project, :attributes_table, :cell_label)) || {}
)
end
end
def styles
@styles ||= PDFStyles.new(styles_asset_path)
end
private
def styles_asset_path
File.dirname(File.expand_path(__FILE__))
end
end
@@ -0,0 +1,179 @@
# 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 Projects::Exports::PDFExport
module TableOfContent
def render_toc(projects, info_map)
with_margin(styles.toc_title_margins) do
pdf.formatted_text([{ text: I18n.t("label_table_of_contents") }.merge(styles.toc_title)])
end
toc_list = build_toc_data_list(projects, info_map)
with_margin(styles.toc_margins) do
write_toc! toc_list
end
start_new_page_if_needed
end
def build_toc_data_list(projects, info_map)
projects.map do |project|
build_toc_data_list_entry(project, info_map)
end
end
def build_toc_data_list_entry(project, info_map)
project_info = info_map[project.id]
level_data = calculate_level_data(project_info[:level_path])
page_nr_string = (project_info[:page_number] || "000").to_s
build_toc_entry(project.id, project.name, level_data, page_nr_string)
end
def calculate_level_data(level_path)
level = [level_path.length, styles.toc_max_depth].min
level_style = styles.toc_item(level)
level_string = "#{level_path.join('.')}. "
{
level:,
level_style:,
level_string:,
level_string_width: measure_part_width(level_string, level_style)
}
end
def build_toc_entry(id, title, level_data, page_nr_string)
{
id:,
level_string: level_data[:level_string],
level_string_width: level_data[:level_string_width],
title:,
page_nr_string:,
page_nr_string_width: measure_part_width(page_nr_string, level_data[:level_style]),
level: level_data[:level]
}
end
def measure_part_width(part, part_style)
measure_text_width(part, part_style) + styles.toc_item_subject_indent
end
def write_toc!(toc_list)
levels_indent_list = toc_indent_list(toc_list)
toc_list.each do |toc_item|
with_margin(styles.toc_item_margins(toc_item[:level])) do
write_toc_item!(toc_item, levels_indent_list)
end
end
end
def toc_indent_list(toc_list)
levels = toc_list.pluck(:level).uniq.sort
level_max_widths = levels.map do |level|
toc_list.select { |item| item[:level] == level }.pluck(:level_string_width).max
end
mode = (styles.toc_indent_mode || :flat).to_sym
case mode
when :stairs
toc_indent_list_stairs(levels, level_max_widths)
when :third_level
toc_indent_list_third_level(levels, level_max_widths)
else
toc_indent_list_flat(levels, level_max_widths)
end
end
def toc_indent_list_flat(levels, level_max_widths)
levels_max_width = level_max_widths.max
levels.map do |_|
{ level_indent: 0, subject_index: levels_max_width }
end
end
def toc_indent_list_third_level(levels, level_max_widths)
indent_list = []
first_section = level_max_widths[0..1].max || 0
second_section = level_max_widths[2..].max || 0
levels.each do |level|
if level < 3
indent_list.push({ level_indent: 0, subject_index: first_section })
else
indent_list.push({ level_indent: first_section, subject_index: first_section + second_section })
end
end
indent_list
end
def toc_indent_list_stairs(levels, level_max_widths)
indent_list = []
levels.each do |level|
level_indent = level <= 1 ? 0 : indent_list.last[:subject_index]
subject_index = level_indent + level_max_widths[level - 1]
indent_list.push({ level_indent:, subject_index: })
end
indent_list
end
def build_toc_item_styles(toc_item)
toc_item_style = styles.toc_item(toc_item[:level])
part_style = toc_item_style.clone
font_styles = part_style.delete(:styles) || []
part_style[:style] = font_styles[0] unless font_styles.empty?
[part_style, toc_item_style]
end
def write_toc_item!(toc_item, levels_indent_list)
y_start_position = pdf.y
part_style, toc_item_style = build_toc_item_styles(toc_item)
indent = levels_indent_list[toc_item[:level] - 1]
write_toc_part_float(indent[:level_indent], toc_item[:level_string], part_style)
write_toc_part_float(0, toc_item[:page_nr_string], part_style.merge({ align: :right }))
write_toc_item_subject(toc_item, indent[:subject_index], toc_item_style)
write_toc_item_link(toc_item, y_start_position)
end
def write_toc_item_subject(toc_item, indent, subject_style)
pdf.indent(indent, toc_item[:page_nr_string_width]) do
pdf.formatted_text([subject_style.merge({ text: toc_item[:title] })])
end
end
def write_toc_part_float(indent, part, part_style)
pdf.float do
pdf.indent(indent) do
pdf.text(part, part_style)
end
end
end
def write_toc_item_link(toc_item, y_start_position)
rect = [pdf.bounds.absolute_right, pdf.y, pdf.bounds.absolute_left, y_start_position]
pdf.link_annotation(rect, Border: [0, 0, 0], Dest: toc_item[:id].to_s)
end
end
end
@@ -42,13 +42,17 @@ module Projects::Exports
end
def projects
@projects ||= query
@projects ||= all_projects
.page(page)
.per_page(Setting.work_packages_projects_export_limit.to_i)
end
def all_projects
query
.results
.with_required_storage
.with_latest_activity
.includes(:custom_values)
.page(page)
.per_page(Setting.work_packages_projects_export_limit.to_i)
end
private
+3 -1
View File
@@ -36,6 +36,7 @@ module Queries
class_methods do
def from_hash(hash) # rubocop:disable Metrics/AbcSize
new(user: hash[:user]).tap do |query|
query.name = hash[:name] if hash[:name].present?
query.add_filters hash[:filters] if hash[:filters].present?
query.add_orders hash[:orders] if hash[:orders].present?
query.group hash[:group_by] if hash[:group_by].present?
@@ -50,7 +51,8 @@ module Queries
orders: orders.map { |o| [o.attribute, o.direction] },
group_by: respond_to?(:group_by) ? group_by : nil,
selects: selects.map(&:attribute),
user:
user:,
name:
}
end
+4
View File
@@ -34,6 +34,10 @@ module Projects
class ExportJob < ::Exports::ExportJob
self.model = Project
def title
I18n.t("export.your_projects_export")
end
private
def prepare!
+3
View File
@@ -50,12 +50,15 @@ Rails.application.configure do |application|
formatter WorkPackage, WorkPackage::Exports::Formatters::SpentUnits
list Project, Projects::Exports::CSV
list Project, Projects::Exports::PDF
formatter Project, Exports::Formatters::CustomField
formatter Project, Exports::Formatters::CustomFieldPdf
formatter Project, Projects::Exports::Formatters::Status
formatter Project, Projects::Exports::Formatters::Description
formatter Project, Projects::Exports::Formatters::Public
formatter Project, Projects::Exports::Formatters::Active
formatter Project, Projects::Exports::Formatters::Favored
formatter Project, Projects::Exports::Formatters::RequiredDiskSpace
end
end
end
+1
View File
@@ -2487,6 +2487,7 @@ en:
label: "Include descriptions"
caption: "This option will add a description column in raw format."
your_work_packages_export: "Work packages are being exported"
your_projects_export: "Projects are being exported"
succeeded: "Export completed"
failed: "An error has occurred while trying to export the work packages: %{message}"
errors:
@@ -0,0 +1,112 @@
/*
* -- 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.
* ++
*/
import { ApplicationController } from 'stimulus-use';
import { renderStreamMessage } from '@hotwired/turbo';
import { HttpErrorResponse } from '@angular/common/http';
import { TurboHelpers } from 'core-turbo/helpers';
export default class AsyncJobDialogController extends ApplicationController {
static values = {
closeDialogId: String,
};
declare closeDialogIdValue:string;
connect() {
this.element.addEventListener('click', (e) => {
e.preventDefault();
TurboHelpers.showProgressBar();
this.closePreviousDialog();
this.requestJob().then((job_id) => {
if (job_id) {
return this.showJobModal(job_id);
}
return this.handleError('No job ID returned from server.');
})
.catch((error:HttpErrorResponse|string) => this.handleError(error))
.finally(() => {
TurboHelpers.hideProgressBar();
});
});
}
closePreviousDialog() {
if (!this.closeDialogIdValue) {
return; // No dialog ID specified, nothing to close
}
const dialog = document.getElementById(this.closeDialogIdValue) as HTMLDialogElement;
if (dialog) {
dialog.close();
}
}
async requestJob():Promise<string> {
const response = await fetch(this.href, {
method: this.method,
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json() as { job_id:string };
if (!result.job_id) {
throw new Error('Invalid response from server');
}
return result.job_id;
}
async showJobModal(job_id:string) {
const response = await fetch(`/job_statuses/${job_id}/dialog`, {
method: 'GET',
headers: { Accept: 'text/vnd.turbo-stream.html' },
});
if (response.ok) {
renderStreamMessage(await response.text());
} else {
throw new Error(response.statusText || 'Invalid response from server');
}
}
async handleError(error:HttpErrorResponse|string):Promise<void> {
void window.OpenProject.getPluginContext().then((pluginContext) => {
pluginContext.services.notifications.addError(error);
});
}
get href() {
return (this.element as HTMLLinkElement).href;
}
get method() {
return (this.element as HTMLLinkElement).dataset.jobHrefMethod || 'GET';
}
}
+2
View File
@@ -7,3 +7,5 @@ styles:
path: "modules/reporting/app/workers/cost_query/pdf"
- name: "meeting"
path: "modules/meeting/app/workers/meetings/pdf"
- name: "project-report"
path: "app/models/projects/exports/pdf_export"