+ <%= submit_tag t('bcf.bcf_xml.import.button_perform_import'), class: 'button -highlight' %>
+ <%= link_to t(:button_cancel),
+ { action: :index },
+ class: 'button' %>
+<% end %>
diff --git a/modules/bcf/config/locales/en.yml b/modules/bcf/config/locales/en.yml
new file mode 100644
index 00000000000..cd3f7e3ff0c
--- /dev/null
+++ b/modules/bcf/config/locales/en.yml
@@ -0,0 +1,30 @@
+# English strings go here for Rails i18n
+en:
+ bcf:
+ label_bcf: 'BCF'
+ linked_issues: "Linked issues"
+ experimental_badge: "Experimental"
+
+ x_bcf_issues:
+ zero: 'No BCF issues'
+ one: 'One BCF issue'
+ other: '%{count} BCF issues'
+
+ bcf_xml:
+ xml_file: 'BCF XML File'
+ import_title: 'Import from BCF file'
+ export: 'Export all to BCF-XML'
+ import_update_comment: '(Updated in BCF import)'
+ import_failed: 'Cannot import BCF file: %{error}'
+ import_successful: 'Imported %{count} BCF issues'
+ type_not_active: "The issue type is not activated for this project."
+
+ import:
+ num_issues_found: '%{x_bcf_issues} are contained in the BCF-XML file, their details are listed below.'
+ button_prepare: 'Prepare import'
+ button_perform_import: 'Confirm import'
+ description: "Provide a BCF-XML v2.1 file to import into this project. You can examine its contents before performing the import."
+ perform_description: "Do you want to import or update the issues listed above?"
+ export:
+ format:
+ bcf: "BCF-XML"
diff --git a/modules/bcf/config/routes.rb b/modules/bcf/config/routes.rb
new file mode 100644
index 00000000000..ec4e6f23e1e
--- /dev/null
+++ b/modules/bcf/config/routes.rb
@@ -0,0 +1,46 @@
+#-- copyright
+# OpenProject Backlogs Plugin
+#
+# Copyright (C)2013-2014 the OpenProject Foundation (OPF)
+# Copyright (C)2011 Stephan Eckardt, Tim Felgentreff, Marnen Laibow-Koser, Sandro Munda
+# Copyright (C)2010-2011 friflaj
+# Copyright (C)2010 Maxime Guilbot, Andrew Vit, Joakim Kolsjö, ibussieres, Daniel Passos, Jason Vasquez, jpic, Emiliano Heyns
+# Copyright (C)2009-2010 Mark Maglana
+# Copyright (C)2009 Joe Heck, Nate Lowrie
+#
+# 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 Backlogs is a derivative work based on ChiliProject Backlogs.
+# The copyright follows:
+# Copyright (C) 2010-2011 - Emiliano Heyns, Mark Maglana, friflaj
+# Copyright (C) 2011 - Jens Ulferts, Gregor Schmidt - Finn GmbH - Berlin, Germany
+#
+# 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 doc/COPYRIGHT.rdoc for more details.
+#++
+
+OpenProject::Application.routes.draw do
+ scope '', as: 'bcf' do
+ scope 'projects/:project_id', as: 'project' do
+ resources :linked_issues, controller: 'bcf/linked_issues' do
+ get :import, action: :import, on: :collection
+ post :prepare_import, action: :prepare_import, on: :collection
+ post :import, action: :perform_import, on: :collection
+ end
+ end
+ end
+end
diff --git a/modules/bcf/db/migrate/20181214103300_add_bcf_plugin.rb b/modules/bcf/db/migrate/20181214103300_add_bcf_plugin.rb
new file mode 100644
index 00000000000..5831a4d5967
--- /dev/null
+++ b/modules/bcf/db/migrate/20181214103300_add_bcf_plugin.rb
@@ -0,0 +1,36 @@
+class AddBcfPlugin < ActiveRecord::Migration[5.1]
+
+ def change
+ create_table :bcf_issues do |t|
+ t.text :uuid, index: true
+ t.column :markup, :xml
+
+ t.references :project, foreign_key: { on_delete: :cascade }, index: true
+ t.references :work_package, foreign_key: { on_delete: :cascade }, index: { unique: true }
+ end
+
+ create_table :bcf_viewpoints do |t|
+ t.text :uuid, index: true
+ t.column :viewpoint, :xml
+ t.text :viewpoint_name
+
+ t.references :issue,
+ foreign_key: { to_table: :bcf_issues, on_delete: :cascade }
+
+ # Create unique index on issue and uuid to avoid duplicates on resynchronization
+ t.index %i[uuid issue_id], unique: true
+ end
+
+ create_table :bcf_comments do |t|
+ t.text :uuid, index: true
+ t.references :journal, index: true
+
+ t.references :issue,
+ foreign_key: { to_table: :bcf_issues, on_delete: :cascade },
+ index: true
+
+ # Create unique index on issue and uuid to avoid duplicates on resynchronization
+ t.index %i[uuid issue_id], unique: true
+ end
+ end
+end
diff --git a/modules/bcf/lib/open_project/bcf.rb b/modules/bcf/lib/open_project/bcf.rb
new file mode 100644
index 00000000000..b99fc418955
--- /dev/null
+++ b/modules/bcf/lib/open_project/bcf.rb
@@ -0,0 +1,6 @@
+module OpenProject
+ module Bcf
+ require "open_project/bcf/engine"
+ require "open_project/bcf/bcf_xml"
+ end
+end
diff --git a/modules/bcf/lib/open_project/bcf/bcf_xml.rb b/modules/bcf/lib/open_project/bcf/bcf_xml.rb
new file mode 100644
index 00000000000..24ce5b6b780
--- /dev/null
+++ b/modules/bcf/lib/open_project/bcf/bcf_xml.rb
@@ -0,0 +1,5 @@
+module OpenProject::Bcf
+ module Bcf
+ require_relative './bcf_xml/importer'
+ end
+end
diff --git a/modules/bcf/lib/open_project/bcf/bcf_xml/exporter.rb b/modules/bcf/lib/open_project/bcf/bcf_xml/exporter.rb
new file mode 100644
index 00000000000..b1f9b45da18
--- /dev/null
+++ b/modules/bcf/lib/open_project/bcf/bcf_xml/exporter.rb
@@ -0,0 +1,145 @@
+require 'fileutils'
+
+module OpenProject::Bcf::BcfXml
+ class Exporter < ::WorkPackage::Exporter::Base
+ include Redmine::I18n
+
+ def current_user
+ User.current
+ end
+
+ def work_packages
+ super.includes(:journals, bcf_issue: [:comments, { viewpoints: :attachments }])
+ end
+
+ def list
+ Dir.mktmpdir do |dir|
+ files = create_bcf! dir
+
+ zip = zip_folder dir, files
+ yield success(zip)
+ end
+ rescue StandardError => e
+ Rails.logger.error "Failed to export work package list #{e} #{e.message}"
+ raise e
+ end
+
+ def success(zip)
+ WorkPackage::Exporter::Success
+ .new format: :xls,
+ content: zip,
+ title: bcf_filename,
+ mime_type: 'application/octet-stream'
+ end
+
+ def bcf_filename
+ # We often have an internal query name that is not meant
+ # for public use or was given by a user.
+ if query.name.present? && query.name != '_'
+ return sane_filename("#{query.name}.bcfzip")
+ end
+
+ sane_filename(
+ "#{Setting.app_title} #{I18n.t(:label_work_package_plural)} \
+ #{format_time_as_date(Time.now, '%Y-%m-%d')}.bcfzip"
+ )
+ end
+
+ def zip_folder(dir, files)
+ zip_file = File.join(dir, bcf_filename)
+
+ Zip::File.open(zip_file, Zip::File::CREATE) do |zip|
+ files.each do |file|
+ name = file.sub("#{dir}/", "")
+ zip.add name, file
+ end
+ end
+
+ File.open(zip_file, 'r')
+ end
+
+ def create_bcf!(bcf_folder)
+ manifest_file = write_manifest(bcf_folder)
+ files = [manifest_file]
+
+ work_packages.find_each do |wp|
+ # Update or create the BCF issue from the given work package
+ issue = IssueWriter.update_from!(wp)
+
+ # Create a folder for the issue
+ issue_folder = topic_folder_for(bcf_folder, issue)
+
+ # Append the markup itself
+ files << topic_markup_file(issue_folder, issue)
+
+ # Append any viewpoints
+ files.concat viewpoints_for(issue_folder, issue)
+
+ # TODO additional files such as BIM snippets
+ end
+
+ files
+ end
+
+ ##
+ # Write the manifest file /bcf.version
+ def write_manifest(dir)
+ File.join(dir, "bcf.version").tap do |manifest_file|
+ dump_file manifest_file, manifest_xml
+ end
+ end
+
+ ##
+ # Create and return the issue folder
+ # /dir//
+ def topic_folder_for(dir, issue)
+ File.join(dir, issue.uuid).tap do |issue_dir|
+ Dir.mkdir issue_dir
+ end
+ end
+
+ ##
+ # Write each work package BCF
+ def topic_markup_file(issue_dir, issue)
+ File.join(issue_dir, 'markup.bcf').tap do |file|
+ dump_file file, issue.markup
+ end
+ end
+
+ ##
+ # Write viewpoints
+ def viewpoints_for(issue_dir, issue)
+ [].tap do |files|
+ issue.viewpoints.find_each do |vp|
+ vp_file = File.join(issue_dir, vp.viewpoint_name)
+ snapshot_file = File.join(issue_dir, vp.snapshot.filename)
+
+ # Copy the files
+ dump_file vp_file, vp.viewpoint
+ FileUtils.cp vp.snapshot.local_path, snapshot_file
+
+ files << vp_file << snapshot_file
+ end
+ end
+ end
+
+ def manifest_xml
+ Nokogiri::XML::Builder.new do |xml|
+ xml.comment created_by_comment
+ xml.Version "xmlns:xsi" => "http://www.w3.org/2001/XMLSchema-instance", "xmlns:xsd" => "http://www.w3.org/2001/XMLSchema", "VersionId" => "2.1" do
+ xml.DetailedVersion "2.1"
+ end
+ end.to_xml
+ end
+
+ def dump_file(path, content)
+ File.open(path, "w") do |f|
+ f.write content
+ end
+ end
+
+ def created_by_comment
+ " Created by #{Setting.app_title} #{OpenProject::VERSION} at #{Time.now} "
+ end
+ end
+end
diff --git a/modules/bcf/lib/open_project/bcf/bcf_xml/file_entry.rb b/modules/bcf/lib/open_project/bcf/bcf_xml/file_entry.rb
new file mode 100644
index 00000000000..87de3c8abeb
--- /dev/null
+++ b/modules/bcf/lib/open_project/bcf/bcf_xml/file_entry.rb
@@ -0,0 +1,14 @@
+##
+# Helper class to provide uploads from IO streams.
+module OpenProject::Bcf::BcfXml
+ class FileEntry < StringIO
+
+ def initialize(stream, filename:)
+ super(stream.read)
+ @original_filename = filename
+ end
+
+ attr_reader :original_filename
+ alias :path :original_filename
+ end
+end
diff --git a/modules/bcf/lib/open_project/bcf/bcf_xml/importer.rb b/modules/bcf/lib/open_project/bcf/bcf_xml/importer.rb
new file mode 100644
index 00000000000..849c98b728f
--- /dev/null
+++ b/modules/bcf/lib/open_project/bcf/bcf_xml/importer.rb
@@ -0,0 +1,67 @@
+require 'activerecord-import'
+require_relative 'issue_reader'
+
+module OpenProject::Bcf::BcfXml
+ class Importer
+
+ attr_reader :project, :zip, :current_user
+
+ def initialize(project, current_user:)
+ @project = project
+ @current_user = current_user
+ end
+
+ ##
+ # Get a list of issues contained in a BCF
+ # but do not perform the import
+ def get_extractor_list!(file)
+ Zip::File.open(file) do |zip|
+ yield_topic_entries(zip)
+ .map do |entry|
+ to_listing(MarkupExtractor.new(entry))
+ end
+ end
+ end
+
+ def import!(file)
+ Zip::File.open(file) do |zip|
+ # Extract all topics of the zip and save them
+ synchronize_topics(zip)
+
+ # TODO: Extract documents
+
+ # TODO: Extract BIM snippets
+ end
+ rescue => e
+ Rails.logger.error "Failed to import BCF Zip #{file}: #{e} #{e.message}"
+ Rails.logger.debug { e.backtrace.join("\n") }
+ raise e
+ end
+
+ private
+
+ def to_listing(extractor)
+ keys = %i[uuid title priority status description author assignee modified_author due_date]
+ Hash[keys.map { |k| [k, extractor.public_send(k)] }].tap do |attributes|
+ attributes[:viewpoint_count] = extractor.viewpoints.count
+ attributes[:comments_count] = extractor.comments.count
+ end
+ end
+
+ def synchronize_topics(zip)
+ yield_topic_entries(zip)
+ .map do |entry|
+ issue = IssueReader.new(project, zip, entry, current_user: current_user).extract!
+ issue.save
+ end
+ .count
+ end
+
+ ##
+ # Yields topic entries and their uuid from the ZIP files
+ # while skipping all other entries
+ def yield_topic_entries(zip)
+ zip.select { |entry| entry.name.end_with?('markup.bcf') }
+ end
+ end
+end
diff --git a/modules/bcf/lib/open_project/bcf/bcf_xml/issue_reader.rb b/modules/bcf/lib/open_project/bcf/bcf_xml/issue_reader.rb
new file mode 100644
index 00000000000..9eb17bc0abe
--- /dev/null
+++ b/modules/bcf/lib/open_project/bcf/bcf_xml/issue_reader.rb
@@ -0,0 +1,201 @@
+##
+# Extracts sections of a BCF markup file
+# manually. If we want to extract the entire markup,
+# this should be turned into a representable/xml decorator
+require_relative 'file_entry'
+
+module OpenProject::Bcf::BcfXml
+ class IssueReader
+
+ attr_reader :zip, :entry, :issue, :extractor, :project, :user, :type
+
+ def initialize(project, zip, entry, current_user:)
+ @zip = zip
+ @entry = entry
+ @project = project
+ @user = current_user
+ @issue = find_or_initialize_issue
+ @extractor = MarkupExtractor.new(entry)
+
+ # TODO fixed type
+ @type = ::Type.find_by(name: 'Issue [BCF]')
+ end
+
+ def extract!
+ issue.markup = extractor.markup
+
+ # Viewpoints will be extended on import
+ build_viewpoints
+
+ # Synchronize with a work package
+ synchronize_with_work_package
+
+ # Comments will be extended on import
+ build_comments
+
+ issue
+ end
+
+ private
+
+ def synchronize_with_work_package
+ call =
+ if issue.work_package
+ update_work_package
+ else
+ create_work_package
+ end
+
+ if call.success?
+ wp = call.result
+ issue.work_package = wp
+ create_comment(user, I18n.t('bcf.bcf_xml.import_update_comment')) unless wp.previous_changes.empty?
+ else
+ Rails.logger.error "Failed to synchronize BCF #{issue.uuid} with work package: #{call.errors.full_messages.join("; ")}"
+ end
+ end
+
+ def create_work_package
+ wp = WorkPackage.new work_package_attributes
+
+ CreateWorkPackageService
+ .new(user: user)
+ .call(wp, send_notifications: false)
+ end
+
+ def update_work_package
+ WorkPackages::UpdateService
+ .new(user: user, work_package: issue.work_package)
+ .call(attributes: work_package_attributes, send_notifications: false)
+ end
+
+ ##
+ # Get mapped and raw attributes from MarkupExtractor
+ # and return all values that are non-nil
+ def work_package_attributes
+ {
+ # Fixed attributes we know
+ project: project,
+ type: type,
+
+ # Native attributes from the extractor
+ subject: extractor.title,
+ description: extractor.description,
+ due_date: extractor.due_date,
+
+ # Mapped attributes
+ author: find_user_in_project(extractor.author),
+ assigned_to: find_user_in_project(extractor.assignee),
+ status_id: statuses.fetch(extractor.status, statuses[:default]),
+ priority_id: priorities.fetch(extractor.priority, priorities[:default]),
+ }.compact
+ end
+
+ ##
+ # Extend comments with new or updated values from XML
+ def build_comments
+ extractor.comments.each do |data|
+ next if issue.comments.has_uuid?(data[:uuid])
+ comment = issue.comments.build data.slice(:uuid)
+
+ # Cannot link to a journal when no work package
+ next if issue.work_package.nil?
+ author = get_comment_author(data)
+ call = create_comment(author, data[:comment])
+
+ if call.success?
+ comment.journal = call.result
+ else
+ Rails.logger.error "Failed to create comment for BCF #{issue.uuid}: #{call.errors.full_messages.join("; ")}"
+ end
+ end
+ end
+
+ ##
+ # Try to find an author with the given mail address
+ def get_comment_author(comment)
+ author = find_user_in_project(comment[:author])
+
+ # If none found, use the current user
+ return user if author.nil?
+
+ # If found, check if the author can comment
+ return user unless author.allowed_to?(:add_work_package_notes, project)
+
+ author
+ end
+
+ ##
+ # Try to find the given user by mail in the project
+ def find_user_in_project(mail)
+ project.users.find_by(mail: mail)
+ end
+
+ def create_comment(author, content)
+ ::AddWorkPackageNoteService
+ .new(user: author, work_package: issue.work_package)
+ .call(content)
+ end
+
+ ##
+ # Extract viewpoints from XML
+ def build_viewpoints
+ extractor.viewpoints.each do |vp|
+ next if issue.viewpoints.has_uuid?(vp[:uuid])
+
+ issue.viewpoints.build(
+ issue: issue,
+ uuid: vp[:uuid],
+
+ # Save the viewpoint as XML
+ viewpoint: read_entry(vp[:viewpoint]),
+ viewpoint_name: vp[:viewpoint],
+
+ # Save the snapshot as file attachment
+ snapshot: as_file_entry(vp[:snapshot])
+ )
+ end
+ end
+
+ ##
+ # Find existing issue or create new
+ def find_or_initialize_issue
+ ::Bcf::Issue.find_or_initialize_by(uuid: topic_uuid, project_id: project.id)
+ end
+
+ ##
+ # Get the topic name of an entry
+ def topic_uuid
+ entry.name.split('/').first
+ end
+
+ ##
+ # Get an entry within the uuid
+ def as_file_entry(filename)
+ entry = zip.find_entry [topic_uuid, filename].join('/')
+
+ if entry
+ FileEntry.new(entry.get_input_stream, filename: filename)
+ end
+ end
+
+ ##
+ # Read an entry as string
+ def read_entry(filename)
+ entry = zip.find_entry [topic_uuid, filename].join('/')
+ entry.get_input_stream.read
+ end
+
+ ##
+ # Keep a hash map of current status ids for faster lookup
+ def statuses
+ @statuses ||= Hash[Status.pluck(:name, :id)].merge(default: Status.default.id)
+ end
+
+ ##
+ # Keep a hash map of current status ids for faster lookup
+ def priorities
+ @priorities ||= Hash[IssuePriority.pluck(:name, :id)].merge(default: IssuePriority.default.try(:id))
+ end
+ end
+end
diff --git a/modules/bcf/lib/open_project/bcf/bcf_xml/issue_writer.rb b/modules/bcf/lib/open_project/bcf/bcf_xml/issue_writer.rb
new file mode 100644
index 00000000000..4bf7662aa1e
--- /dev/null
+++ b/modules/bcf/lib/open_project/bcf/bcf_xml/issue_writer.rb
@@ -0,0 +1,180 @@
+##
+# Creates or updates a BCF issue and markup from a work package
+module OpenProject::Bcf::BcfXml
+ class IssueWriter
+
+ attr_reader :work_package, :issue, :markup_doc, :markup_node
+
+ def self.update_from!(work_package)
+ writer = new(work_package)
+ writer.update
+
+ writer.issue
+ end
+
+ def initialize(work_package)
+ @work_package = work_package
+ @issue = find_or_initialize_issue
+
+ # Read the existing markup XML or build an empty one
+ @markup_doc = build_markup_document
+
+ # Remember root markup node for easier access
+ @markup_node = @markup_doc.at_xpath('/Markup')
+ end
+
+ def update
+
+ # Replace topic node
+ replace_topic
+
+ # Override all current comments
+ replace_comments
+
+ # Override all current Viewpoints
+ replace_viewpoints
+
+ # Replace the markup XML
+ issue.markup = markup_doc.to_xml
+
+ # Save issue and potential new associations
+ issue.save!
+ end
+
+ private
+
+ ##
+ # Get the nokogiri document from the markup xml
+ def build_markup_document
+ if issue.markup
+ Nokogiri::XML issue.markup
+ else
+ build_initial_markup_xml.doc
+ end
+ end
+
+ ##
+ # Initial markup file as basic BCF compliant xml
+ def build_initial_markup_xml
+ Nokogiri::XML::Builder.new do |xml|
+ xml.comment created_by_comment
+ xml.Markup "xmlns:xsi" => "http://www.w3.org/2001/XMLSchema-instance", "xmlns:xsd" => "http://www.w3.org/2001/XMLSchema"
+ end
+ end
+
+ ##
+ # Replace the topic node, if any
+ def replace_topic
+ markup_node.xpath('./Topic').remove
+
+ Nokogiri::XML::Builder.with(markup_node) do |xml|
+ topic xml
+ end
+ end
+
+ ##
+ # Render the topic of the work package
+ def topic(xml)
+ xml.Topic "Guid" => issue.uuid,
+ "TopicType" => work_package.type.name,
+ "TopicStatus" => work_package.status.name do
+ xml.Title work_package.subject
+ xml.CreationDate to_bcf_datetime(work_package.created_at)
+ xml.ModifiedDate to_bcf_datetime(work_package.updated_at)
+ xml.Description work_package.description
+ xml.CreationAuthor work_package.author.mail
+ xml.ReferenceLink url_helpers.work_package_url(work_package)
+
+ if priority = work_package.priority
+ xml.Priority priority.name
+ end
+
+ if work_package.due_date
+ xml.DueDate to_bcf_date(work_package.due_date)
+ end
+
+ if journal = work_package.journals.select(:user_id).last
+ xml.ModifiedAuthor journal.user.mail if journal.user_id
+ end
+
+ if assignee = work_package.assigned_to
+ xml.AssignedTo assignee.mail
+ end
+ end
+ end
+
+ def replace_comments
+ markup_node.xpath('./Comment').remove
+
+ Nokogiri::XML::Builder.with(markup_node) do |xml|
+ comments xml
+ end
+ end
+
+ def replace_viewpoints
+ markup_node.xpath('./Viewpoints').remove
+
+ Nokogiri::XML::Builder.with(markup_node) do |xml|
+ viewpoints xml
+ end
+ end
+
+ ##
+ # Render the comments of the work package as XML nodes
+ def comments(xml)
+ comments = issue.comments.group_by(&:journal_id)
+ work_package.journals.select(:id, :notes, :user_id, :created_at).map do |journal|
+ next if journal.notes.empty?
+
+ # Create BCF comment reference for the journal
+ comment = comments[journal.id]&.first || issue.comments.build(issue_id: issue, journal_id: journal.id)
+ comment_node xml, comment.uuid, journal, work_package
+ end
+ end
+
+ ##
+ # Create a single topic node
+ def comment_node(xml, uuid, journal, work_package)
+ xml.Comment "Guid" => uuid do
+ xml.Date to_bcf_datetime(journal.created_at)
+ xml.Author journal.user.mail if journal.user_id
+ xml.Comment journal.notes
+ end
+ end
+
+ ##
+ # Write the current set of viewpoints
+ def viewpoints(xml)
+ issue.viewpoints.find_each do |vp|
+ xml.Viewpoints "Guid" => vp.uuid do
+ xml.Viewpoint vp.viewpoint_name
+ xml.Snapshot vp.snapshot.filename
+ end
+ end
+ end
+
+ ##
+ #
+ def created_by_comment
+ " Created by #{Setting.app_title} #{OpenProject::VERSION} at #{Time.now} "
+ end
+
+ ##
+ # Find existing issue or create new
+ def find_or_initialize_issue
+ ::Bcf::Issue.find_or_initialize_by(work_package: work_package, project_id: work_package.project_id)
+ end
+
+ def to_bcf_datetime(date_time)
+ date_time.utc.iso8601
+ end
+
+ def to_bcf_date(date)
+ date.iso8601
+ end
+
+ def url_helpers
+ @url_helpers ||= OpenProject::StaticRouting::StaticUrlHelpers.new
+ end
+ end
+end
diff --git a/modules/bcf/lib/open_project/bcf/bcf_xml/markup_extractor.rb b/modules/bcf/lib/open_project/bcf/bcf_xml/markup_extractor.rb
new file mode 100644
index 00000000000..ac8b196d96e
--- /dev/null
+++ b/modules/bcf/lib/open_project/bcf/bcf_xml/markup_extractor.rb
@@ -0,0 +1,84 @@
+##
+# Extracts sections of a BCF markup file
+# manually. If we want to extract the entire markup,
+# this should be turned into a representable/xml decorator
+
+module OpenProject::Bcf::BcfXml
+ class MarkupExtractor
+
+ attr_reader :entry, :markup, :doc
+
+ def initialize(entry)
+ @markup = entry.get_input_stream.read
+ @doc = Nokogiri::XML markup
+ end
+
+ def uuid
+ extract_non_empty :@Guid, attribute: true
+ end
+
+ def title
+ extract_non_empty :Title
+ end
+
+ def priority
+ extract_non_empty :Priority
+ end
+
+ def status
+ extract_non_empty :@TopicStatus, attribute: true
+ end
+
+ def description
+ extract_non_empty :Description
+ end
+
+ def author
+ extract_non_empty :CreationAuthor
+ end
+
+ def assignee
+ extract_non_empty :AssignedTo
+ end
+
+ def modified_author
+ extract_non_empty :ModifiedAuthor
+ end
+
+ def due_date
+ date = extract_non_empty :DueDate
+ Date.iso8601(date) unless date.nil?
+ rescue ArgumentError
+ nil
+ end
+
+ def viewpoints
+ doc.xpath('/Markup/Viewpoints').map do |node|
+ {
+ uuid: node['Guid'],
+ viewpoint: node.xpath('Viewpoint/text()').to_s,
+ snapshot: node.xpath('Snapshot/text()').to_s
+ }
+ end
+ end
+
+ def comments
+ doc.xpath('/Markup/Comment').map do |node|
+ {
+ uuid: node['Guid'],
+ date: node.xpath('Date/text()').to_s,
+ author: node.xpath('Author/text()').to_s,
+ comment: node.xpath('Comment/text()').to_s
+ }
+ end
+ end
+
+ private
+
+ def extract_non_empty(path, prefix: '/Markup/Topic/'.freeze, attribute: false)
+ suffix = attribute ? '' : '/text()'.freeze
+ path = [prefix, path.to_s, suffix].join('')
+ doc.xpath(path).to_s.presence
+ end
+ end
+end
diff --git a/modules/bcf/lib/open_project/bcf/engine.rb b/modules/bcf/lib/open_project/bcf/engine.rb
new file mode 100644
index 00000000000..45d35b20ef9
--- /dev/null
+++ b/modules/bcf/lib/open_project/bcf/engine.rb
@@ -0,0 +1,59 @@
+require 'open_project/plugins'
+
+module OpenProject::Bcf
+ class Engine < ::Rails::Engine
+ engine_name :openproject_bcf
+
+ include OpenProject::Plugins::ActsAsOpEngine
+
+ register 'openproject-bcf',
+ author_url: 'https://openproject.com',
+ settings: {
+ default: {
+ }
+ } do
+
+ project_module :bcf do
+ permission :view_linked_issues,
+ 'bcf/linked_issues': :index
+
+ permission :manage_bcf,
+ 'bcf/linked_issues': %i[index import prepare_import perform_import]
+ end
+
+ menu :project_menu,
+ :bcf,
+ { controller: '/bcf/linked_issues', action: :index },
+ caption: :'bcf.label_bcf',
+ param: :project_id,
+ icon: 'icon2 icon-backlogs',
+ badge: 'bcf.experimental_badge'
+ end
+
+ assets %w(bcf/bcf.css)
+
+ patches %i[WorkPackage]
+
+ patch_with_namespace :BasicData, :SettingSeeder
+
+ extend_api_response(:v3, :work_packages, :work_package_collection) do
+ require_relative 'patches/api/v3/export_formats'
+
+ prepend Patches::Api::V3::ExportFormats
+ end
+
+ initializer 'bcf.register_hooks' do
+ # don't use require_dependency to not reload hooks in development mode
+ require 'open_project/xls_export/hooks/work_package_hook.rb'
+ end
+
+ initializer 'bcf.register_mimetypes' do
+ Mime::Type.register "application/octet-stream", :bcf unless Mime::Type.lookup_by_extension(:bcf)
+ end
+
+ config.to_prepare do
+ WorkPackage::Exporter
+ .register_for_list(:bcf, OpenProject::Bcf::BcfXml::Exporter)
+ end
+ end
+end
diff --git a/modules/bcf/lib/open_project/bcf/patches/api/v3/export_formats.rb b/modules/bcf/lib/open_project/bcf/patches/api/v3/export_formats.rb
new file mode 100644
index 00000000000..6e7bb68c843
--- /dev/null
+++ b/modules/bcf/lib/open_project/bcf/patches/api/v3/export_formats.rb
@@ -0,0 +1,12 @@
+module OpenProject::Bcf::Patches
+ module Api::V3::ExportFormats
+ def representation_formats
+ super + [representation_format_bcf]
+ end
+
+ def representation_format_bcf
+ representation_format :bcf,
+ mime_type: 'application/octet-stream'
+ end
+ end
+end
diff --git a/modules/bcf/lib/open_project/bcf/patches/setting_seeder_patch.rb b/modules/bcf/lib/open_project/bcf/patches/setting_seeder_patch.rb
new file mode 100644
index 00000000000..e1ee7187a8b
--- /dev/null
+++ b/modules/bcf/lib/open_project/bcf/patches/setting_seeder_patch.rb
@@ -0,0 +1,36 @@
+#-- copyright
+# OpenProject Costs Plugin
+#
+# Copyright (C) 2009 - 2014 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# version 3.
+#
+# 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.
+#++
+
+module OpenProject::Bcf::Patches::SettingSeederPatch
+ def self.included(base) # :nodoc:
+ base.prepend InstanceMethods
+ end
+
+ module InstanceMethods
+ def data
+ original_data = super
+
+ unless original_data['default_projects_modules'].include? 'bcf'
+ original_data['default_projects_modules'] << 'bcf'
+ end
+
+ original_data
+ end
+ end
+end
diff --git a/modules/bcf/lib/open_project/bcf/patches/work_package_patch.rb b/modules/bcf/lib/open_project/bcf/patches/work_package_patch.rb
new file mode 100644
index 00000000000..e95eb531c9b
--- /dev/null
+++ b/modules/bcf/lib/open_project/bcf/patches/work_package_patch.rb
@@ -0,0 +1,44 @@
+#-- copyright
+# OpenProject Backlogs Plugin
+#
+# Copyright (C)2013-2014 the OpenProject Foundation (OPF)
+# Copyright (C)2011 Stephan Eckardt, Tim Felgentreff, Marnen Laibow-Koser, Sandro Munda
+# Copyright (C)2010-2011 friflaj
+# Copyright (C)2010 Maxime Guilbot, Andrew Vit, Joakim Kolsjö, ibussieres, Daniel Passos, Jason Vasquez, jpic, Emiliano Heyns
+# Copyright (C)2009-2010 Mark Maglana
+# Copyright (C)2009 Joe Heck, Nate Lowrie
+#
+# 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 Backlogs is a derivative work based on ChiliProject Backlogs.
+# The copyright follows:
+# Copyright (C) 2010-2011 - Emiliano Heyns, Mark Maglana, friflaj
+# Copyright (C) 2011 - Jens Ulferts, Gregor Schmidt - Finn GmbH - Berlin, Germany
+#
+# 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 doc/COPYRIGHT.rdoc for more details.
+#++
+
+require_dependency 'work_package'
+
+module OpenProject::Bcf::Patches::WorkPackagePatch
+ def self.included(base)
+ base.class_eval do
+ has_one :bcf_issue, class_name: 'Bcf::Issue', foreign_key: 'work_package_id'
+ end
+ end
+end
diff --git a/modules/bcf/lib/open_project/bcf/version.rb b/modules/bcf/lib/open_project/bcf/version.rb
new file mode 100644
index 00000000000..bbdebba05b9
--- /dev/null
+++ b/modules/bcf/lib/open_project/bcf/version.rb
@@ -0,0 +1,7 @@
+require 'open_project/version'
+
+module OpenProject
+ module Bcf
+ VERSION = ::OpenProject::VERSION.to_semver
+ end
+end
diff --git a/modules/bcf/lib/openproject-bcf.rb b/modules/bcf/lib/openproject-bcf.rb
new file mode 100644
index 00000000000..bbf40eeb2f7
--- /dev/null
+++ b/modules/bcf/lib/openproject-bcf.rb
@@ -0,0 +1 @@
+require 'open_project/bcf'
diff --git a/modules/bcf/openproject-bcf.gemspec b/modules/bcf/openproject-bcf.gemspec
new file mode 100644
index 00000000000..10762cab8ad
--- /dev/null
+++ b/modules/bcf/openproject-bcf.gemspec
@@ -0,0 +1,23 @@
+# encoding: UTF-8
+$:.push File.expand_path("../lib", __FILE__)
+$:.push File.expand_path("../../lib", __dir__)
+
+require "open_project/bcf/version"
+
+# Describe your gem and declare its dependencies:
+Gem::Specification.new do |s|
+ s.name = "openproject-bcf"
+ s.version = OpenProject::Bcf::VERSION
+ s.authors = "OpenProject GmbH"
+ s.email = "info@openproject.com"
+ s.homepage = "https://community.openproject.org/"
+ s.summary = "OpenProject BCF import/export"
+ s.description = "This OpenProject plugin introduces BCF functionality"
+
+ s.files = Dir["{app,config,db,lib}/**/*", "CHANGELOG.md", "README.rdoc"]
+ s.test_files = Dir["spec/**/*"]
+
+ s.add_dependency 'rails', '~> 5'
+ s.add_dependency 'rubyzip', '~> 1.2'
+ s.add_dependency 'activerecord-import'
+end
diff --git a/modules/bim_seeder/app/seeders/bim_seeder/basic_data/type_seeder.rb b/modules/bim_seeder/app/seeders/bim_seeder/basic_data/type_seeder.rb
index fe6d04e406f..ceb4da0c61e 100644
--- a/modules/bim_seeder/app/seeders/bim_seeder/basic_data/type_seeder.rb
+++ b/modules/bim_seeder/app/seeders/bim_seeder/basic_data/type_seeder.rb
@@ -32,7 +32,7 @@ module BimSeeder
module BasicData
class TypeSeeder < ::BasicData::TypeSeeder
def type_names
- %i[task milestone phase building_model defect approval]
+ %i[task milestone phase building_model defect approval bcf_issue]
end
def type_table
@@ -42,7 +42,8 @@ module BimSeeder
phase: [3, true, :default_color_blue_dark, false, false, :default_type_phase],
building_model: [4, true, :default_color_blue, true, false, 'seeders.bim.default_type_building_model'],
defect: [5, true, :default_color_red, true, false, 'seeders.bim.default_type_defect'],
- approval: [6, true, :default_color_grey_dark, true, false, 'seeders.bim.default_type_approval']
+ approval: [6, true, :default_color_grey_dark, true, false, 'seeders.bim.default_type_approval'],
+ bcf_issue: [6, true, :default_color_grey_red, true, false, 'seeders.bim.default_type_bcf_issue']
}
end
end
diff --git a/modules/bim_seeder/config/locales/en.seeders.bim.yml b/modules/bim_seeder/config/locales/en.seeders.bim.yml
index 900df21a27f..94e2304bd01 100644
--- a/modules/bim_seeder/config/locales/en.seeders.bim.yml
+++ b/modules/bim_seeder/config/locales/en.seeders.bim.yml
@@ -31,6 +31,7 @@ en:
default_type_building_model: Building model
default_type_defect: Defect
default_type_approval: Approval
+ default_type_bcf_issue: Issue [BCF]
demo_data:
welcome:
title: "Welcome to OpenProject BIM Edition!"
@@ -57,6 +58,7 @@ en:
- work_package_tracking
- news
- wiki
+ - bcf
news:
- title: Welcome to your demo project
summary: >
@@ -70,6 +72,7 @@ en:
- 'seeders.bim.default_type_building_model'
- 'seeders.bim.default_type_defect'
- 'seeders.bim.default_type_approval'
+ - 'seeders.bim.default_type_bcf_issue'
categories:
- Category 1 (to be changed in Project settings)
queries:
@@ -105,6 +108,9 @@ en:
- type
- status
- assigned_to
+ - name: Issues [BCF]
+ status: open
+ type: 'seeders.bim.default_type_bcf_issue'
work_packages:
- subject: Project kick-off
description: Plan and execute the project kick-off.
@@ -181,10 +187,10 @@ en:
Please activate further [Modules](%{base_url}/projects/demo-project/settings/modules) in the Project settings in order to have more features in your project.
**You can:**
- * add a Scrum module (Backlogs),
* add time tracking, reporting, and budgets (Time Tracking, Cost Reports, Budgets),
* add a wiki,
* add meetings,
+ * add BCF import/export,
* and more.
**Visuals:**
diff --git a/modules/bim_seeder/openproject-bim_seeder.gemspec b/modules/bim_seeder/openproject-bim_seeder.gemspec
index a278d0579b4..84fbf7ef84c 100644
--- a/modules/bim_seeder/openproject-bim_seeder.gemspec
+++ b/modules/bim_seeder/openproject-bim_seeder.gemspec
@@ -15,4 +15,6 @@ Gem::Specification.new do |s|
s.version = "1.0.0"
s.files = Dir["{app,lib,config}/**/*"] + %w(CHANGELOG.md README.md)
+
+ s.add_dependency "openproject-bcf"
end
diff --git a/modules/bim_seeder/spec/seeders/demo_data_seeder_spec.rb b/modules/bim_seeder/spec/seeders/demo_data_seeder_spec.rb
index 3f56411ca05..f28c55bbecf 100644
--- a/modules/bim_seeder/spec/seeders/demo_data_seeder_spec.rb
+++ b/modules/bim_seeder/spec/seeders/demo_data_seeder_spec.rb
@@ -59,7 +59,7 @@ describe 'seeds' do
expect(Project.count).to eq 1
expect(WorkPackage.count).to eq 18
expect(Wiki.count).to eq 1
- expect(Query.count).to eq 4
+ expect(Query.count).to eq 5
ensure
ActionMailer::Base.perform_deliveries = perform_deliveries
end
diff --git a/modules/xls_export/lib/open_project/xls_export/filename_helper.rb b/modules/xls_export/lib/open_project/xls_export/filename_helper.rb
deleted file mode 100644
index 3d140027ab0..00000000000
--- a/modules/xls_export/lib/open_project/xls_export/filename_helper.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-module OpenProject::XlsExport
- class FilenameHelper
- # Remove characters that could cause problems on popular OSses
- # => A string that does not start with a space or dot and does not contain any of \/:*?"<>|
- def self.sane_filename(str)
- str.gsub(/^[ \.]/,"").gsub(/[\\\/:\*\?"<>|"]/, "_")
- end
- end
-end
diff --git a/modules/xls_export/lib/open_project/xls_export/work_package_xls_export.rb b/modules/xls_export/lib/open_project/xls_export/work_package_xls_export.rb
index b488dec42f6..b007929729d 100644
--- a/modules/xls_export/lib/open_project/xls_export/work_package_xls_export.rb
+++ b/modules/xls_export/lib/open_project/xls_export/work_package_xls_export.rb
@@ -29,7 +29,7 @@ module OpenProject
enable! WithDescription if with_descriptions
enable! WithRelations if with_relations
- success(spreadsheet.xls)
+ yield success(spreadsheet.xls)
end
def success(content)
@@ -107,7 +107,7 @@ module OpenProject
end
def xls_export_filename
- FilenameHelper.sane_filename(
+ sane_filename(
"#{Setting.app_title} #{I18n.t(:label_work_package_plural)} \
#{format_time_as_date(Time.now, '%Y-%m-%d')}.xls"
)
diff --git a/package.json b/package.json
index 075b61ba732..cb151674fcc 100644
--- a/package.json
+++ b/package.json
@@ -13,5 +13,8 @@
"private": true,
"engines": {
"node": "~8.12.0"
+ },
+ "dependencies": {
+ "webfonts-generator": "^0.4.0"
}
}