diff --git a/Gemfile.lock b/Gemfile.lock index 48e50a8cf6a..e9579cee39f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -124,10 +124,19 @@ PATH acts_as_silent_list (~> 3.0.0) openproject-pdf_export +PATH + remote: modules/bcf + specs: + openproject-bcf (9.0.0) + activerecord-import + rails (~> 5) + rubyzip (~> 1.2) + PATH remote: modules/bim_seeder specs: openproject-bim_seeder (1.0.0) + openproject-bcf PATH remote: modules/boards @@ -975,6 +984,7 @@ DEPENDENCIES openproject-auth_saml! openproject-avatars! openproject-backlogs! + openproject-bcf! openproject-bim_seeder! openproject-boards! openproject-costs! diff --git a/Gemfile.modules b/Gemfile.modules index 860d2ae172b..ff05bcd1480 100644 --- a/Gemfile.modules +++ b/Gemfile.modules @@ -43,4 +43,5 @@ group :opf_plugins do gem 'openproject-boards', path: 'modules/boards' gem 'openproject-bim_seeder', path: 'modules/bim_seeder', require: !!(ENV['OPENPROJECT_EDITION'] == 'bim') + gem 'openproject-bcf', path: 'modules/bcf', require: !!(ENV['OPENPROJECT_EDITION'] == 'bim') end diff --git a/app/assets/fonts/openproject_icon/openproject-icon-font.svg b/app/assets/fonts/openproject_icon/openproject-icon-font.svg index 9a49fcda398..c7fd4fefd87 100644 --- a/app/assets/fonts/openproject_icon/openproject-icon-font.svg +++ b/app/assets/fonts/openproject_icon/openproject-icon-font.svg @@ -229,509 +229,515 @@ - + - - + - - + - + + diff --git a/app/assets/fonts/openproject_icon/openproject-icon-font.ttf b/app/assets/fonts/openproject_icon/openproject-icon-font.ttf index e168f16c671..d4c3c19d6a9 100644 Binary files a/app/assets/fonts/openproject_icon/openproject-icon-font.ttf and b/app/assets/fonts/openproject_icon/openproject-icon-font.ttf differ diff --git a/app/assets/fonts/openproject_icon/openproject-icon-font.woff b/app/assets/fonts/openproject_icon/openproject-icon-font.woff index 41dda4cd13e..6e59b710e32 100644 Binary files a/app/assets/fonts/openproject_icon/openproject-icon-font.woff and b/app/assets/fonts/openproject_icon/openproject-icon-font.woff differ diff --git a/app/assets/fonts/openproject_icon/openproject-icon-font.woff2 b/app/assets/fonts/openproject_icon/openproject-icon-font.woff2 index 202667b47e3..234d12b7768 100644 Binary files a/app/assets/fonts/openproject_icon/openproject-icon-font.woff2 and b/app/assets/fonts/openproject_icon/openproject-icon-font.woff2 differ diff --git a/app/assets/fonts/openproject_icon/src/export-bcf.svg b/app/assets/fonts/openproject_icon/src/export-bcf.svg new file mode 100644 index 00000000000..c5ba623c5c5 --- /dev/null +++ b/app/assets/fonts/openproject_icon/src/export-bcf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/fonts/openproject_icon/src/import.svg b/app/assets/fonts/openproject_icon/src/import.svg new file mode 100644 index 00000000000..e7c50a49ba2 --- /dev/null +++ b/app/assets/fonts/openproject_icon/src/import.svg @@ -0,0 +1,53 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/app/assets/stylesheets/fonts/_openproject_icon_definitions.scss b/app/assets/stylesheets/fonts/_openproject_icon_definitions.scss index 5ea57592b7c..885c93ea0f3 100644 --- a/app/assets/stylesheets/fonts/_openproject_icon_definitions.scss +++ b/app/assets/stylesheets/fonts/_openproject_icon_definitions.scss @@ -448,1011 +448,1023 @@ .icon-export-atom:before { content: "\f14a"; } -@mixin icon-mixin-export-csv { +@mixin icon-mixin-export-bcf { content: "\f14b"; } -.icon-export-csv:before { +.icon-export-bcf:before { content: "\f14b"; } -@mixin icon-mixin-export-pdf-descr { +@mixin icon-mixin-export-csv { content: "\f14c"; } -.icon-export-pdf-descr:before { +.icon-export-csv:before { content: "\f14c"; } -@mixin icon-mixin-export-pdf-with-descriptions { +@mixin icon-mixin-export-pdf-descr { content: "\f14d"; } -.icon-export-pdf-with-descriptions:before { +.icon-export-pdf-descr:before { content: "\f14d"; } -@mixin icon-mixin-export-pdf { +@mixin icon-mixin-export-pdf-with-descriptions { content: "\f14e"; } -.icon-export-pdf:before { +.icon-export-pdf-with-descriptions:before { content: "\f14e"; } -@mixin icon-mixin-export-xls-descr { +@mixin icon-mixin-export-pdf { content: "\f14f"; } -.icon-export-xls-descr:before { +.icon-export-pdf:before { content: "\f14f"; } -@mixin icon-mixin-export-xls-with-descriptions { +@mixin icon-mixin-export-xls-descr { content: "\f150"; } -.icon-export-xls-with-descriptions:before { +.icon-export-xls-descr:before { content: "\f150"; } -@mixin icon-mixin-export-xls-with-relations { +@mixin icon-mixin-export-xls-with-descriptions { content: "\f151"; } -.icon-export-xls-with-relations:before { +.icon-export-xls-with-descriptions:before { content: "\f151"; } -@mixin icon-mixin-export-xls { +@mixin icon-mixin-export-xls-with-relations { content: "\f152"; } -.icon-export-xls:before { +.icon-export-xls-with-relations:before { content: "\f152"; } -@mixin icon-mixin-export { +@mixin icon-mixin-export-xls { content: "\f153"; } -.icon-export:before { +.icon-export-xls:before { content: "\f153"; } -@mixin icon-mixin-faq { +@mixin icon-mixin-export { content: "\f154"; } -.icon-faq:before { +.icon-export:before { content: "\f154"; } -@mixin icon-mixin-filter { +@mixin icon-mixin-faq { content: "\f155"; } -.icon-filter:before { +.icon-faq:before { content: "\f155"; } -@mixin icon-mixin-flag { +@mixin icon-mixin-filter { content: "\f156"; } -.icon-flag:before { +.icon-filter:before { content: "\f156"; } -@mixin icon-mixin-folder-add { +@mixin icon-mixin-flag { content: "\f157"; } -.icon-folder-add:before { +.icon-flag:before { content: "\f157"; } -@mixin icon-mixin-folder-locked { +@mixin icon-mixin-folder-add { content: "\f158"; } -.icon-folder-locked:before { +.icon-folder-add:before { content: "\f158"; } -@mixin icon-mixin-folder-open { +@mixin icon-mixin-folder-locked { content: "\f159"; } -.icon-folder-open:before { +.icon-folder-locked:before { content: "\f159"; } -@mixin icon-mixin-folder-remove { +@mixin icon-mixin-folder-open { content: "\f15a"; } -.icon-folder-remove:before { +.icon-folder-open:before { content: "\f15a"; } -@mixin icon-mixin-folder { +@mixin icon-mixin-folder-remove { content: "\f15b"; } -.icon-folder:before { +.icon-folder-remove:before { content: "\f15b"; } -@mixin icon-mixin-forums { +@mixin icon-mixin-folder { content: "\f15c"; } -.icon-forums:before { +.icon-folder:before { content: "\f15c"; } -@mixin icon-mixin-from-fullscreen { +@mixin icon-mixin-forums { content: "\f15d"; } -.icon-from-fullscreen:before { +.icon-forums:before { content: "\f15d"; } -@mixin icon-mixin-getting-started { +@mixin icon-mixin-from-fullscreen { content: "\f15e"; } -.icon-getting-started:before { +.icon-from-fullscreen:before { content: "\f15e"; } -@mixin icon-mixin-glossar { +@mixin icon-mixin-getting-started { content: "\f15f"; } -.icon-glossar:before { +.icon-getting-started:before { content: "\f15f"; } -@mixin icon-mixin-google-plus { +@mixin icon-mixin-glossar { content: "\f160"; } -.icon-google-plus:before { +.icon-glossar:before { content: "\f160"; } -@mixin icon-mixin-group-by { +@mixin icon-mixin-google-plus { content: "\f161"; } -.icon-group-by:before { +.icon-google-plus:before { content: "\f161"; } -@mixin icon-mixin-group { +@mixin icon-mixin-group-by { content: "\f162"; } -.icon-group:before { +.icon-group-by:before { content: "\f162"; } -@mixin icon-mixin-hamburger { +@mixin icon-mixin-group { content: "\f163"; } -.icon-hamburger:before { +.icon-group:before { content: "\f163"; } -@mixin icon-mixin-headline1 { +@mixin icon-mixin-hamburger { content: "\f164"; } -.icon-headline1:before { +.icon-hamburger:before { content: "\f164"; } -@mixin icon-mixin-headline2 { +@mixin icon-mixin-headline1 { content: "\f165"; } -.icon-headline2:before { +.icon-headline1:before { content: "\f165"; } -@mixin icon-mixin-headline3 { +@mixin icon-mixin-headline2 { content: "\f166"; } -.icon-headline3:before { +.icon-headline2:before { content: "\f166"; } -@mixin icon-mixin-headset { +@mixin icon-mixin-headline3 { content: "\f167"; } -.icon-headset:before { +.icon-headline3:before { content: "\f167"; } -@mixin icon-mixin-help { +@mixin icon-mixin-headset { content: "\f168"; } -.icon-help:before { +.icon-headset:before { content: "\f168"; } -@mixin icon-mixin-help1 { +@mixin icon-mixin-help { content: "\f169"; } -.icon-help1:before { +.icon-help:before { content: "\f169"; } -@mixin icon-mixin-help2 { +@mixin icon-mixin-help1 { content: "\f16a"; } -.icon-help2:before { +.icon-help1:before { content: "\f16a"; } -@mixin icon-mixin-hierarchy { +@mixin icon-mixin-help2 { content: "\f16b"; } -.icon-hierarchy:before { +.icon-help2:before { content: "\f16b"; } -@mixin icon-mixin-home { +@mixin icon-mixin-hierarchy { content: "\f16c"; } -.icon-home:before { +.icon-hierarchy:before { content: "\f16c"; } -@mixin icon-mixin-hosting { +@mixin icon-mixin-home { content: "\f16d"; } -.icon-hosting:before { +.icon-home:before { content: "\f16d"; } -@mixin icon-mixin-image1 { +@mixin icon-mixin-hosting { content: "\f16e"; } -.icon-image1:before { +.icon-hosting:before { content: "\f16e"; } -@mixin icon-mixin-image2 { +@mixin icon-mixin-image1 { content: "\f16f"; } -.icon-image2:before { +.icon-image1:before { content: "\f16f"; } -@mixin icon-mixin-info1 { +@mixin icon-mixin-image2 { content: "\f170"; } -.icon-info1:before { +.icon-image2:before { content: "\f170"; } -@mixin icon-mixin-info2 { +@mixin icon-mixin-import { content: "\f171"; } -.icon-info2:before { +.icon-import:before { content: "\f171"; } -@mixin icon-mixin-installation-services { +@mixin icon-mixin-info1 { content: "\f172"; } -.icon-installation-services:before { +.icon-info1:before { content: "\f172"; } -@mixin icon-mixin-italic { +@mixin icon-mixin-info2 { content: "\f173"; } -.icon-italic:before { +.icon-info2:before { content: "\f173"; } -@mixin icon-mixin-key { +@mixin icon-mixin-installation-services { content: "\f174"; } -.icon-key:before { +.icon-installation-services:before { content: "\f174"; } -@mixin icon-mixin-link { +@mixin icon-mixin-italic { content: "\f175"; } -.icon-link:before { +.icon-italic:before { content: "\f175"; } -@mixin icon-mixin-loading1 { +@mixin icon-mixin-key { content: "\f176"; } -.icon-loading1:before { +.icon-key:before { content: "\f176"; } -@mixin icon-mixin-loading2 { +@mixin icon-mixin-link { content: "\f177"; } -.icon-loading2:before { +.icon-link:before { content: "\f177"; } -@mixin icon-mixin-location { +@mixin icon-mixin-loading1 { content: "\f178"; } -.icon-location:before { +.icon-loading1:before { content: "\f178"; } -@mixin icon-mixin-locked { +@mixin icon-mixin-loading2 { content: "\f179"; } -.icon-locked:before { +.icon-loading2:before { content: "\f179"; } -@mixin icon-mixin-logout { +@mixin icon-mixin-location { content: "\f17a"; } -.icon-logout:before { +.icon-location:before { content: "\f17a"; } -@mixin icon-mixin-mail1 { +@mixin icon-mixin-locked { content: "\f17b"; } -.icon-mail1:before { +.icon-locked:before { content: "\f17b"; } -@mixin icon-mixin-mail2 { +@mixin icon-mixin-logout { content: "\f17c"; } -.icon-mail2:before { +.icon-logout:before { content: "\f17c"; } -@mixin icon-mixin-maintenance-support { +@mixin icon-mixin-mail1 { content: "\f17d"; } -.icon-maintenance-support:before { +.icon-mail1:before { content: "\f17d"; } -@mixin icon-mixin-meetings { +@mixin icon-mixin-mail2 { content: "\f17e"; } -.icon-meetings:before { +.icon-mail2:before { content: "\f17e"; } -@mixin icon-mixin-menu { +@mixin icon-mixin-maintenance-support { content: "\f17f"; } -.icon-menu:before { +.icon-maintenance-support:before { content: "\f17f"; } -@mixin icon-mixin-microphone { +@mixin icon-mixin-meetings { content: "\f180"; } -.icon-microphone:before { +.icon-meetings:before { content: "\f180"; } -@mixin icon-mixin-milestone { +@mixin icon-mixin-menu { content: "\f181"; } -.icon-milestone:before { +.icon-menu:before { content: "\f181"; } -@mixin icon-mixin-minus1 { +@mixin icon-mixin-microphone { content: "\f182"; } -.icon-minus1:before { +.icon-microphone:before { content: "\f182"; } -@mixin icon-mixin-minus2 { +@mixin icon-mixin-milestone { content: "\f183"; } -.icon-minus2:before { +.icon-milestone:before { content: "\f183"; } -@mixin icon-mixin-mobile { +@mixin icon-mixin-minus1 { content: "\f184"; } -.icon-mobile:before { +.icon-minus1:before { content: "\f184"; } -@mixin icon-mixin-modules { +@mixin icon-mixin-minus2 { content: "\f185"; } -.icon-modules:before { +.icon-minus2:before { content: "\f185"; } -@mixin icon-mixin-more { +@mixin icon-mixin-mobile { content: "\f186"; } -.icon-more:before { +.icon-mobile:before { content: "\f186"; } -@mixin icon-mixin-move { +@mixin icon-mixin-modules { content: "\f187"; } -.icon-move:before { +.icon-modules:before { content: "\f187"; } -@mixin icon-mixin-movie { +@mixin icon-mixin-more { content: "\f188"; } -.icon-movie:before { +.icon-more:before { content: "\f188"; } -@mixin icon-mixin-music { +@mixin icon-mixin-move { content: "\f189"; } -.icon-music:before { +.icon-move:before { content: "\f189"; } -@mixin icon-mixin-new-planning-element { +@mixin icon-mixin-movie { content: "\f18a"; } -.icon-new-planning-element:before { +.icon-movie:before { content: "\f18a"; } -@mixin icon-mixin-news { +@mixin icon-mixin-music { content: "\f18b"; } -.icon-news:before { +.icon-music:before { content: "\f18b"; } -@mixin icon-mixin-no-hierarchy { +@mixin icon-mixin-new-planning-element { content: "\f18c"; } -.icon-no-hierarchy:before { +.icon-new-planning-element:before { content: "\f18c"; } -@mixin icon-mixin-no-zen-mode { +@mixin icon-mixin-news { content: "\f18d"; } -.icon-no-zen-mode:before { +.icon-news:before { content: "\f18d"; } -@mixin icon-mixin-not-supported { +@mixin icon-mixin-no-hierarchy { content: "\f18e"; } -.icon-not-supported:before { +.icon-no-hierarchy:before { content: "\f18e"; } -@mixin icon-mixin-notes { +@mixin icon-mixin-no-zen-mode { content: "\f18f"; } -.icon-notes:before { +.icon-no-zen-mode:before { content: "\f18f"; } -@mixin icon-mixin-openproject { +@mixin icon-mixin-not-supported { content: "\f190"; } -.icon-openproject:before { +.icon-not-supported:before { content: "\f190"; } -@mixin icon-mixin-ordered-list { +@mixin icon-mixin-notes { content: "\f191"; } -.icon-ordered-list:before { +.icon-notes:before { content: "\f191"; } -@mixin icon-mixin-outline { +@mixin icon-mixin-openproject { content: "\f192"; } -.icon-outline:before { +.icon-openproject:before { content: "\f192"; } -@mixin icon-mixin-paragraph-left { +@mixin icon-mixin-ordered-list { content: "\f193"; } -.icon-paragraph-left:before { +.icon-ordered-list:before { content: "\f193"; } -@mixin icon-mixin-paragraph-right { +@mixin icon-mixin-outline { content: "\f194"; } -.icon-paragraph-right:before { +.icon-outline:before { content: "\f194"; } -@mixin icon-mixin-paragraph { +@mixin icon-mixin-paragraph-left { content: "\f195"; } -.icon-paragraph:before { +.icon-paragraph-left:before { content: "\f195"; } -@mixin icon-mixin-payment-history { +@mixin icon-mixin-paragraph-right { content: "\f196"; } -.icon-payment-history:before { +.icon-paragraph-right:before { content: "\f196"; } -@mixin icon-mixin-phone { +@mixin icon-mixin-paragraph { content: "\f197"; } -.icon-phone:before { +.icon-paragraph:before { content: "\f197"; } -@mixin icon-mixin-pin { +@mixin icon-mixin-payment-history { content: "\f198"; } -.icon-pin:before { +.icon-payment-history:before { content: "\f198"; } -@mixin icon-mixin-play { +@mixin icon-mixin-phone { content: "\f199"; } -.icon-play:before { +.icon-phone:before { content: "\f199"; } -@mixin icon-mixin-plugins { +@mixin icon-mixin-pin { content: "\f19a"; } -.icon-plugins:before { +.icon-pin:before { content: "\f19a"; } -@mixin icon-mixin-plus { +@mixin icon-mixin-play { content: "\f19b"; } -.icon-plus:before { +.icon-play:before { content: "\f19b"; } -@mixin icon-mixin-pre { +@mixin icon-mixin-plugins { content: "\f19c"; } -.icon-pre:before { +.icon-plugins:before { content: "\f19c"; } -@mixin icon-mixin-presentation { +@mixin icon-mixin-plus { content: "\f19d"; } -.icon-presentation:before { +.icon-plus:before { content: "\f19d"; } -@mixin icon-mixin-preview { +@mixin icon-mixin-pre { content: "\f19e"; } -.icon-preview:before { +.icon-pre:before { content: "\f19e"; } -@mixin icon-mixin-print { +@mixin icon-mixin-presentation { content: "\f19f"; } -.icon-print:before { +.icon-presentation:before { content: "\f19f"; } -@mixin icon-mixin-priority { +@mixin icon-mixin-preview { content: "\f1a0"; } -.icon-priority:before { +.icon-preview:before { content: "\f1a0"; } -@mixin icon-mixin-project-types { +@mixin icon-mixin-print { content: "\f1a1"; } -.icon-project-types:before { +.icon-print:before { content: "\f1a1"; } -@mixin icon-mixin-projects { +@mixin icon-mixin-priority { content: "\f1a2"; } -.icon-projects:before { +.icon-priority:before { content: "\f1a2"; } -@mixin icon-mixin-publish { +@mixin icon-mixin-project-types { content: "\f1a3"; } -.icon-publish:before { +.icon-project-types:before { content: "\f1a3"; } -@mixin icon-mixin-pulldown-up { +@mixin icon-mixin-projects { content: "\f1a4"; } -.icon-pulldown-up:before { +.icon-projects:before { content: "\f1a4"; } -@mixin icon-mixin-pulldown { +@mixin icon-mixin-publish { content: "\f1a5"; } -.icon-pulldown:before { +.icon-publish:before { content: "\f1a5"; } -@mixin icon-mixin-quote { +@mixin icon-mixin-pulldown-up { content: "\f1a6"; } -.icon-quote:before { +.icon-pulldown-up:before { content: "\f1a6"; } -@mixin icon-mixin-quote2 { +@mixin icon-mixin-pulldown { content: "\f1a7"; } -.icon-quote2:before { +.icon-pulldown:before { content: "\f1a7"; } -@mixin icon-mixin-redo { +@mixin icon-mixin-quote { content: "\f1a8"; } -.icon-redo:before { +.icon-quote:before { content: "\f1a8"; } -@mixin icon-mixin-relation-follows { +@mixin icon-mixin-quote2 { content: "\f1a9"; } -.icon-relation-follows:before { +.icon-quote2:before { content: "\f1a9"; } -@mixin icon-mixin-relation-new-child { +@mixin icon-mixin-redo { content: "\f1aa"; } -.icon-relation-new-child:before { +.icon-redo:before { content: "\f1aa"; } -@mixin icon-mixin-relation-precedes { +@mixin icon-mixin-relation-follows { content: "\f1ab"; } -.icon-relation-precedes:before { +.icon-relation-follows:before { content: "\f1ab"; } -@mixin icon-mixin-relations { +@mixin icon-mixin-relation-new-child { content: "\f1ac"; } -.icon-relations:before { +.icon-relation-new-child:before { content: "\f1ac"; } -@mixin icon-mixin-reload { +@mixin icon-mixin-relation-precedes { content: "\f1ad"; } -.icon-reload:before { +.icon-relation-precedes:before { content: "\f1ad"; } -@mixin icon-mixin-reminder { +@mixin icon-mixin-relations { content: "\f1ae"; } -.icon-reminder:before { +.icon-relations:before { content: "\f1ae"; } -@mixin icon-mixin-remove { +@mixin icon-mixin-reload { content: "\f1af"; } -.icon-remove:before { +.icon-reload:before { content: "\f1af"; } -@mixin icon-mixin-rename { +@mixin icon-mixin-reminder { content: "\f1b0"; } -.icon-rename:before { +.icon-reminder:before { content: "\f1b0"; } -@mixin icon-mixin-reported-by-me { +@mixin icon-mixin-remove { content: "\f1b1"; } -.icon-reported-by-me:before { +.icon-remove:before { content: "\f1b1"; } -@mixin icon-mixin-resizer-vertical-lines { +@mixin icon-mixin-rename { content: "\f1b2"; } -.icon-resizer-vertical-lines:before { +.icon-rename:before { content: "\f1b2"; } -@mixin icon-mixin-roadmap { +@mixin icon-mixin-reported-by-me { content: "\f1b3"; } -.icon-roadmap:before { +.icon-reported-by-me:before { content: "\f1b3"; } -@mixin icon-mixin-rss { +@mixin icon-mixin-resizer-vertical-lines { content: "\f1b4"; } -.icon-rss:before { +.icon-resizer-vertical-lines:before { content: "\f1b4"; } -@mixin icon-mixin-rubber { +@mixin icon-mixin-roadmap { content: "\f1b5"; } -.icon-rubber:before { +.icon-roadmap:before { content: "\f1b5"; } -@mixin icon-mixin-save { +@mixin icon-mixin-rss { content: "\f1b6"; } -.icon-save:before { +.icon-rss:before { content: "\f1b6"; } -@mixin icon-mixin-search { +@mixin icon-mixin-rubber { content: "\f1b7"; } -.icon-search:before { +.icon-rubber:before { content: "\f1b7"; } -@mixin icon-mixin-send-mail { +@mixin icon-mixin-save { content: "\f1b8"; } -.icon-send-mail:before { +.icon-save:before { content: "\f1b8"; } -@mixin icon-mixin-server-key { +@mixin icon-mixin-search { content: "\f1b9"; } -.icon-server-key:before { +.icon-search:before { content: "\f1b9"; } -@mixin icon-mixin-settings { +@mixin icon-mixin-send-mail { content: "\f1ba"; } -.icon-settings:before { +.icon-send-mail:before { content: "\f1ba"; } -@mixin icon-mixin-settings2 { +@mixin icon-mixin-server-key { content: "\f1bb"; } -.icon-settings2:before { +.icon-server-key:before { content: "\f1bb"; } -@mixin icon-mixin-settings3 { +@mixin icon-mixin-settings { content: "\f1bc"; } -.icon-settings3:before { +.icon-settings:before { content: "\f1bc"; } -@mixin icon-mixin-settings4 { +@mixin icon-mixin-settings2 { content: "\f1bd"; } -.icon-settings4:before { +.icon-settings2:before { content: "\f1bd"; } -@mixin icon-mixin-shortcuts { +@mixin icon-mixin-settings3 { content: "\f1be"; } -.icon-shortcuts:before { +.icon-settings3:before { content: "\f1be"; } -@mixin icon-mixin-show-all-projects { +@mixin icon-mixin-settings4 { content: "\f1bf"; } -.icon-show-all-projects:before { +.icon-settings4:before { content: "\f1bf"; } -@mixin icon-mixin-show-more-horizontal { +@mixin icon-mixin-shortcuts { content: "\f1c0"; } -.icon-show-more-horizontal:before { +.icon-shortcuts:before { content: "\f1c0"; } -@mixin icon-mixin-show-more { +@mixin icon-mixin-show-all-projects { content: "\f1c1"; } -.icon-show-more:before { +.icon-show-all-projects:before { content: "\f1c1"; } -@mixin icon-mixin-sort-ascending { +@mixin icon-mixin-show-more-horizontal { content: "\f1c2"; } -.icon-sort-ascending:before { +.icon-show-more-horizontal:before { content: "\f1c2"; } -@mixin icon-mixin-sort-by { +@mixin icon-mixin-show-more { content: "\f1c3"; } -.icon-sort-by:before { +.icon-show-more:before { content: "\f1c3"; } -@mixin icon-mixin-sort-descending { +@mixin icon-mixin-sort-ascending { content: "\f1c4"; } -.icon-sort-descending:before { +.icon-sort-ascending:before { content: "\f1c4"; } -@mixin icon-mixin-sort-down { +@mixin icon-mixin-sort-by { content: "\f1c5"; } -.icon-sort-down:before { +.icon-sort-by:before { content: "\f1c5"; } -@mixin icon-mixin-sort-up { +@mixin icon-mixin-sort-descending { content: "\f1c6"; } -.icon-sort-up:before { +.icon-sort-descending:before { content: "\f1c6"; } -@mixin icon-mixin-square { +@mixin icon-mixin-sort-down { content: "\f1c7"; } -.icon-square:before { +.icon-sort-down:before { content: "\f1c7"; } -@mixin icon-mixin-star { +@mixin icon-mixin-sort-up { content: "\f1c8"; } -.icon-star:before { +.icon-sort-up:before { content: "\f1c8"; } -@mixin icon-mixin-status-reporting { +@mixin icon-mixin-square { content: "\f1c9"; } -.icon-status-reporting:before { +.icon-square:before { content: "\f1c9"; } -@mixin icon-mixin-status { +@mixin icon-mixin-star { content: "\f1ca"; } -.icon-status:before { +.icon-star:before { content: "\f1ca"; } -@mixin icon-mixin-strike-through { +@mixin icon-mixin-status-reporting { content: "\f1cb"; } -.icon-strike-through:before { +.icon-status-reporting:before { content: "\f1cb"; } -@mixin icon-mixin-text { +@mixin icon-mixin-status { content: "\f1cc"; } -.icon-text:before { +.icon-status:before { content: "\f1cc"; } -@mixin icon-mixin-ticket-checked { +@mixin icon-mixin-strike-through { content: "\f1cd"; } -.icon-ticket-checked:before { +.icon-strike-through:before { content: "\f1cd"; } -@mixin icon-mixin-ticket-down { +@mixin icon-mixin-text { content: "\f1ce"; } -.icon-ticket-down:before { +.icon-text:before { content: "\f1ce"; } -@mixin icon-mixin-ticket-edit { +@mixin icon-mixin-ticket-checked { content: "\f1cf"; } -.icon-ticket-edit:before { +.icon-ticket-checked:before { content: "\f1cf"; } -@mixin icon-mixin-ticket-minus { +@mixin icon-mixin-ticket-down { content: "\f1d0"; } -.icon-ticket-minus:before { +.icon-ticket-down:before { content: "\f1d0"; } -@mixin icon-mixin-ticket-note { +@mixin icon-mixin-ticket-edit { content: "\f1d1"; } -.icon-ticket-note:before { +.icon-ticket-edit:before { content: "\f1d1"; } -@mixin icon-mixin-ticket { +@mixin icon-mixin-ticket-minus { content: "\f1d2"; } -.icon-ticket:before { +.icon-ticket-minus:before { content: "\f1d2"; } -@mixin icon-mixin-time { +@mixin icon-mixin-ticket-note { content: "\f1d3"; } -.icon-time:before { +.icon-ticket-note:before { content: "\f1d3"; } -@mixin icon-mixin-to-fullscreen { +@mixin icon-mixin-ticket { content: "\f1d4"; } -.icon-to-fullscreen:before { +.icon-ticket:before { content: "\f1d4"; } -@mixin icon-mixin-toggle { +@mixin icon-mixin-time { content: "\f1d5"; } -.icon-toggle:before { +.icon-time:before { content: "\f1d5"; } -@mixin icon-mixin-training-consulting { +@mixin icon-mixin-to-fullscreen { content: "\f1d6"; } -.icon-training-consulting:before { +.icon-to-fullscreen:before { content: "\f1d6"; } -@mixin icon-mixin-two-factor-authentication { +@mixin icon-mixin-toggle { content: "\f1d7"; } -.icon-two-factor-authentication:before { +.icon-toggle:before { content: "\f1d7"; } -@mixin icon-mixin-types { +@mixin icon-mixin-training-consulting { content: "\f1d8"; } -.icon-types:before { +.icon-training-consulting:before { content: "\f1d8"; } -@mixin icon-mixin-underline { +@mixin icon-mixin-two-factor-authentication { content: "\f1d9"; } -.icon-underline:before { +.icon-two-factor-authentication:before { content: "\f1d9"; } -@mixin icon-mixin-undo { +@mixin icon-mixin-types { content: "\f1da"; } -.icon-undo:before { +.icon-types:before { content: "\f1da"; } -@mixin icon-mixin-unit { +@mixin icon-mixin-underline { content: "\f1db"; } -.icon-unit:before { +.icon-underline:before { content: "\f1db"; } -@mixin icon-mixin-unlocked { +@mixin icon-mixin-undo { content: "\f1dc"; } -.icon-unlocked:before { +.icon-undo:before { content: "\f1dc"; } -@mixin icon-mixin-unordered-list { +@mixin icon-mixin-unit { content: "\f1dd"; } -.icon-unordered-list:before { +.icon-unit:before { content: "\f1dd"; } -@mixin icon-mixin-unwatched { +@mixin icon-mixin-unlocked { content: "\f1de"; } -.icon-unwatched:before { +.icon-unlocked:before { content: "\f1de"; } -@mixin icon-mixin-upload { +@mixin icon-mixin-unordered-list { content: "\f1df"; } -.icon-upload:before { +.icon-unordered-list:before { content: "\f1df"; } -@mixin icon-mixin-user-minus { +@mixin icon-mixin-unwatched { content: "\f1e0"; } -.icon-user-minus:before { +.icon-unwatched:before { content: "\f1e0"; } -@mixin icon-mixin-user-plus { +@mixin icon-mixin-upload { content: "\f1e1"; } -.icon-user-plus:before { +.icon-upload:before { content: "\f1e1"; } -@mixin icon-mixin-user { +@mixin icon-mixin-user-minus { content: "\f1e2"; } -.icon-user:before { +.icon-user-minus:before { content: "\f1e2"; } -@mixin icon-mixin-view-fullscreen { +@mixin icon-mixin-user-plus { content: "\f1e3"; } -.icon-view-fullscreen:before { +.icon-user-plus:before { content: "\f1e3"; } -@mixin icon-mixin-view-list { +@mixin icon-mixin-user { content: "\f1e4"; } -.icon-view-list:before { +.icon-user:before { content: "\f1e4"; } -@mixin icon-mixin-view-split { +@mixin icon-mixin-view-fullscreen { content: "\f1e5"; } -.icon-view-split:before { +.icon-view-fullscreen:before { content: "\f1e5"; } -@mixin icon-mixin-view-timeline { +@mixin icon-mixin-view-list { content: "\f1e6"; } -.icon-view-timeline:before { +.icon-view-list:before { content: "\f1e6"; } -@mixin icon-mixin-warning { +@mixin icon-mixin-view-split { content: "\f1e7"; } -.icon-warning:before { +.icon-view-split:before { content: "\f1e7"; } -@mixin icon-mixin-watched { +@mixin icon-mixin-view-timeline { content: "\f1e8"; } -.icon-watched:before { +.icon-view-timeline:before { content: "\f1e8"; } -@mixin icon-mixin-wiki-edit { +@mixin icon-mixin-warning { content: "\f1e9"; } -.icon-wiki-edit:before { +.icon-warning:before { content: "\f1e9"; } -@mixin icon-mixin-wiki { +@mixin icon-mixin-watched { content: "\f1ea"; } -.icon-wiki:before { +.icon-watched:before { content: "\f1ea"; } -@mixin icon-mixin-wiki2 { +@mixin icon-mixin-wiki-edit { content: "\f1eb"; } -.icon-wiki2:before { +.icon-wiki-edit:before { content: "\f1eb"; } -@mixin icon-mixin-work-packages { +@mixin icon-mixin-wiki { content: "\f1ec"; } -.icon-work-packages:before { +.icon-wiki:before { content: "\f1ec"; } -@mixin icon-mixin-workflow { +@mixin icon-mixin-wiki2 { content: "\f1ed"; } -.icon-workflow:before { +.icon-wiki2:before { content: "\f1ed"; } -@mixin icon-mixin-yes { +@mixin icon-mixin-work-packages { content: "\f1ee"; } -.icon-yes:before { +.icon-work-packages:before { content: "\f1ee"; } -@mixin icon-mixin-zen-mode { +@mixin icon-mixin-workflow { content: "\f1ef"; } -.icon-zen-mode:before { +.icon-workflow:before { content: "\f1ef"; } -@mixin icon-mixin-zoom-auto { +@mixin icon-mixin-yes { content: "\f1f0"; } -.icon-zoom-auto:before { +.icon-yes:before { content: "\f1f0"; } -@mixin icon-mixin-zoom-in { +@mixin icon-mixin-zen-mode { content: "\f1f1"; } -.icon-zoom-in:before { +.icon-zen-mode:before { content: "\f1f1"; } -@mixin icon-mixin-zoom-out { +@mixin icon-mixin-zoom-auto { content: "\f1f2"; } -.icon-zoom-out:before { +.icon-zoom-auto:before { content: "\f1f2"; } +@mixin icon-mixin-zoom-in { + content: "\f1f3"; +} +.icon-zoom-in:before { + content: "\f1f3"; +} +@mixin icon-mixin-zoom-out { + content: "\f1f4"; +} +.icon-zoom-out:before { + content: "\f1f4"; +} diff --git a/app/assets/stylesheets/fonts/_openproject_icon_font.lsg b/app/assets/stylesheets/fonts/_openproject_icon_font.lsg index 395bd810e6e..7bb7ae9898b 100644 --- a/app/assets/stylesheets/fonts/_openproject_icon_font.lsg +++ b/app/assets/stylesheets/fonts/_openproject_icon_font.lsg @@ -77,6 +77,7 @@
  • enumerations
  • error
  • export-atom
  • +
  • export-bcf
  • export-csv
  • export-pdf-descr
  • export-pdf-with-descriptions
  • @@ -114,6 +115,7 @@
  • hosting
  • image1
  • image2
  • +
  • import
  • info1
  • info2
  • installation-services
  • diff --git a/app/assets/stylesheets/layout/_main_menu.sass b/app/assets/stylesheets/layout/_main_menu.sass index 3d4b781635d..bc00ff93265 100644 --- a/app/assets/stylesheets/layout/_main_menu.sass +++ b/app/assets/stylesheets/layout/_main_menu.sass @@ -400,3 +400,21 @@ a.main-menu--parent-node .tree-menu--title @include varprop(color, main-menu-hover-font-color) text-decoration: none + + + +// Badges for menu items such as "EXPERIMENTAL" or "BETA" +$badge_offset: 4px + +.menu-item--title.-has-badge + // As this is flex box with a vertical center we need this trick to undo centering the whole content including a badge. + // The goal is to leave the caption where it was before adding a badge. + margin-top: -1 * $badge_offset + +.main-item--badge + font-size: 10px + vertical-align: $badge_offset + text-transform: uppercase + margin-left: 4px + font-weight: bold + font-style: italic diff --git a/app/controllers/work_packages_controller.rb b/app/controllers/work_packages_controller.rb index 44a5490f223..ec83e6d51f4 100644 --- a/app/controllers/work_packages_controller.rb +++ b/app/controllers/work_packages_controller.rb @@ -87,29 +87,15 @@ class WorkPackagesController < ApplicationController def export_list(mime_type) exporter = WorkPackage::Exporter.for_list(mime_type) - export = exporter.list(@query, params) - - if export.error? - flash[:error] = export.message - redirect_back(fallback_location: index_redirect_path) - else - send_data(export.content, - type: export.mime_type, - filename: export.title) + exporter.list(@query, params) do |export| + render_export_response export, fallback_path: index_redirect_path end end def export_single(mime_type) exporter = WorkPackage::Exporter.for_single(mime_type) - export = exporter.single(work_package, params) - - if export.error? - flash[:error] = export.message - redirect_back(fallback_location: work_package_path(work_packages)) - else - send_data(export.content, - type: export.mime_type, - filename: export.title) + exporter.single(work_package, params) do |export| + render_export_response export, fallback_path: work_package_path(work_packages) end end @@ -126,13 +112,36 @@ class WorkPackagesController < ApplicationController title: "#{@project || Setting.app_title}: #{l(:label_work_package_plural)}") end + private + + def render_export_response(export, fallback_path:) + if export.error? + flash[:error] = export.message + redirect_back(fallback_location: fallback_path) + elsif export.content.is_a? File + # browsers should not try to guess the content-type + response.headers['X-Content-Type-Options'] = 'nosniff' + + # TODO avoid reading the file in memory here again + # but currently the tempfile gets removed in between + send_data(export.content.read, + type: export.mime_type, + disposition: 'attachment', + filename: export.title) + else + send_data(export.content, + type: export.mime_type, + filename: export.title) + end + end + def authorize_on_work_package deny_access unless work_package end def protect_from_unauthorized_export if supported_export_formats.include?(params[:format]) && - !User.current.allowed_to?(:export_work_packages, @project, global: @project.nil?) + !User.current.allowed_to?(:export_work_packages, @project, global: @project.nil?) deny_access false @@ -187,7 +196,7 @@ class WorkPackagesController < ApplicationController .changing .includes(:user) .order(order).to_a - end + end end def index_redirect_path @@ -198,8 +207,6 @@ class WorkPackagesController < ApplicationController end end - private - def load_work_packages @results = @query.results @work_packages = if @query.valid? diff --git a/app/models/attachment.rb b/app/models/attachment.rb index 4df4abdf254..c698ed1396d 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -128,19 +128,33 @@ class Attachment < ActiveRecord::Base container.present? end + ## + # Retrieve a local file, + # this may result in downloading the file first def diskfile file.local_file end + ## + # Retrieve the local file path, + # this may result in downloading the file first to a tmpdir + def local_path + diskfile.path + end + def filename attributes['file'] end def file=(file) super.tap do - set_content_type file set_file_size file - set_digest file + + set_content_type file + + if File.readable? file.path + set_digest file + end end end diff --git a/app/models/work_package/exporter/base.rb b/app/models/work_package/exporter/base.rb index d190ed82c04..df68f273649 100644 --- a/app/models/work_package/exporter/base.rb +++ b/app/models/work_package/exporter/base.rb @@ -37,14 +37,17 @@ class WorkPackage::Exporter::Base self.options = options end - def self.list(query, options = {}) - new(query, options).list + def self.list(query, options = {}, &block) + new(query, options).list(&block) end - def self.single(work_package, options = {}) - new(work_package, options).single + def self.single(work_package, options = {}, &block) + new(work_package, options).single(&block) end + # Provide means to clean up after the export + def cleanup; end + def page options[:page] || 1 end @@ -58,6 +61,15 @@ class WorkPackage::Exporter::Base alias :query :object alias :work_package :object + # Remove characters that could cause problems on popular OSses + def sane_filename(name) + parts = name.split /(?<=.)\.(?=[^.])(?!.*\.[^.])/m + + parts.map! { |s| s.gsub /[^a-z0-9\-]+/i, '_' } + + parts.join '.' + end + def work_packages @work_packages ||= query .results diff --git a/app/models/work_package/exporter/csv.rb b/app/models/work_package/exporter/csv.rb index cc261378f2e..95487038166 100644 --- a/app/models/work_package/exporter/csv.rb +++ b/app/models/work_package/exporter/csv.rb @@ -45,7 +45,7 @@ class WorkPackage::Exporter::CSV < WorkPackage::Exporter::Base end end - success(serialized) + yield success(serialized) end def self.encode_csv_columns(columns, encoding = l(:general_csv_encoding)) diff --git a/app/models/work_package/exporter/pdf.rb b/app/models/work_package/exporter/pdf.rb index 87a5ec284b4..938eba3e48f 100644 --- a/app/models/work_package/exporter/pdf.rb +++ b/app/models/work_package/exporter/pdf.rb @@ -31,7 +31,7 @@ class WorkPackage::Exporter::PDF < WorkPackage::Exporter::Base # Returns a PDF string of a list of work_packages def list - ::WorkPackage::PdfExport::WorkPackageListToPdf + yield ::WorkPackage::PdfExport::WorkPackageListToPdf .new(query, options) .render! @@ -39,7 +39,7 @@ class WorkPackage::Exporter::PDF < WorkPackage::Exporter::Base # Returns a PDF string of a single work_package def single - ::WorkPackage::PdfExport::WorkPackageToPdf + yield ::WorkPackage::PdfExport::WorkPackageToPdf .new(work_package) .render! end diff --git a/app/models/work_package/exporter/success.rb b/app/models/work_package/exporter/success.rb index e6677b492f9..79c265bc2e1 100644 --- a/app/models/work_package/exporter/success.rb +++ b/app/models/work_package/exporter/success.rb @@ -34,7 +34,7 @@ class WorkPackage::Exporter::Success < WorkPackage::Exporter::Result :content, :mime_type - def initialize(format:, title:, content:, mime_type:) + def initialize(format:, title:, content: nil, mime_type:) self.format = format self.title = title self.content = content diff --git a/app/services/add_work_package_note_service.rb b/app/services/add_work_package_note_service.rb index 674e04067e3..5eccca27db9 100644 --- a/app/services/add_work_package_note_service.rb +++ b/app/services/add_work_package_note_service.rb @@ -45,12 +45,12 @@ class AddWorkPackageNoteService JournalManager.with_send_notifications send_notifications do work_package.add_journal(user, notes) - result, errors = validate_and_yield(work_package, user) do + success, errors = validate_and_yield(work_package, user) do work_package.save_journals end - ServiceResult.new(success: result, - errors: errors) + journal = work_package.journals.last if success + ServiceResult.new(success: success, result: journal, errors: errors) end end end diff --git a/app/services/create_work_package_service.rb b/app/services/create_work_package_service.rb index 182445d82bc..1e411c3d28c 100644 --- a/app/services/create_work_package_service.rb +++ b/app/services/create_work_package_service.rb @@ -61,5 +61,7 @@ class CreateWorkPackageService def assign_defaults(work_package) work_package.author ||= user + work_package.priority ||= IssuePriority.active.default + work_package.status ||= Status.default end end diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index 5af23d1f0db..92702644a3d 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -31,6 +31,20 @@ module FileUploader base.extend ClassMethods end + ## + # Returns an URL if the attachment is stored in an external (fog) attachment storage + # or nil otherwise. + def external_url + url = URI.parse download_url + url if url.host + rescue URI::InvalidURIError + nil + end + + def external_storage? + !external_url.nil? + end + def local_file file.to_file end @@ -48,7 +62,7 @@ module FileUploader end # store! nil's the cache_id after it finishes so we need to remember it for deletion - def remember_cache_id(_new_file) + def remember_cache_id(_new_file) @cache_id_was = cache_id end diff --git a/config/locales/en.yml b/config/locales/en.yml index 3ed081bbfb0..bacd7987971 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1304,6 +1304,7 @@ en: label_equals: "is" label_everywhere: "everywhere" label_example: "Example" + label_import: "Import" label_export_to: "Also available in:" label_expanded_click_to_collapse: "Expanded. Click to collapse" label_f_hour: "%{value} hour" diff --git a/lib/redmine/menu_manager/menu_helper.rb b/lib/redmine/menu_manager/menu_helper.rb index e89d0868285..d1e4c7b0e00 100644 --- a/lib/redmine/menu_manager/menu_helper.rb +++ b/lib/redmine/menu_manager/menu_helper.rb @@ -184,14 +184,21 @@ module Redmine::MenuManager::MenuHelper end def render_single_menu_node(item, caption, url, selected) + link_text = ''.html_safe link_text << op_icon(item.icon) if item.icon.present? link_text << you_are_here_info(selected) - link_text << content_tag(:span, caption, class: 'menu-item--title ellipsis', lang: menu_item_locale(item)) + link_text << content_tag(:span, + class: "menu-item--title ellipsis #{item.badge.present? ? '-has-badge' : ''}", + lang: menu_item_locale(item)) do + ''.html_safe + caption + badge_for(item) + end link_text << ' '.html_safe + op_icon(item.icon_after) if item.icon_after.present? html_options = item.html_options(selected: selected) html_options[:title] ||= selected ? t(:description_current_position) + caption : caption - link_to link_text, url, html_options + link_to url, html_options do + link_text + end end def render_unattached_menu_item(menu_item, project) @@ -265,10 +272,10 @@ module Redmine::MenuManager::MenuHelper end if project - return user && user.allowed_to?(node.url, project) + user && user.allowed_to?(node.url, project) else # outside a project, all menu items allowed - return true + true end end @@ -309,4 +316,12 @@ module Redmine::MenuManager::MenuHelper def wiki_prefix? current_menu_item.to_s.match? /^wiki-/ end + + def badge_for(item) + badge = ''.html_safe + if item.badge.present? + badge += ' '.html_safe + content_tag('span', I18n.t(item.badge), class: 'main-item--badge') + end + badge + end end diff --git a/lib/redmine/menu_manager/menu_item.rb b/lib/redmine/menu_manager/menu_item.rb index 7c20b7c6014..24375e2c8c8 100644 --- a/lib/redmine/menu_manager/menu_item.rb +++ b/lib/redmine/menu_manager/menu_item.rb @@ -29,7 +29,7 @@ class Redmine::MenuManager::MenuItem < Redmine::MenuManager::TreeNode include Redmine::I18n - attr_reader :name, :url, :param, :icon, :icon_after, :context, :condition, :parent, :child_menus, :last, :partial + attr_reader :name, :url, :param, :icon, :icon_after, :context, :condition, :parent, :child_menus, :last, :partial, :badge def initialize(name, url, options) raise ArgumentError, "Invalid option :if for menu item '#{name}'" if options[:if] && !options[:if].respond_to?(:call) @@ -53,6 +53,7 @@ class Redmine::MenuManager::MenuItem < Redmine::MenuManager::TreeNode @child_menus = options[:children] @last = options[:last] || false @partial = options[:partial] + @badge = options[:badge] super @name.to_sym end diff --git a/modules/bcf/Gemfile b/modules/bcf/Gemfile new file mode 100644 index 00000000000..851fabc21dd --- /dev/null +++ b/modules/bcf/Gemfile @@ -0,0 +1,2 @@ +source 'https://rubygems.org' +gemspec diff --git a/modules/bcf/app/assets/stylesheets/bcf/bcf.sass b/modules/bcf/app/assets/stylesheets/bcf/bcf.sass new file mode 100644 index 00000000000..cd03476d8fe --- /dev/null +++ b/modules/bcf/app/assets/stylesheets/bcf/bcf.sass @@ -0,0 +1,20 @@ +.bcf--issues + display: flex + flex-direction: row + flex-wrap: wrap + + > div + flex: 0 0 250px + margin: 10px + padding: 10px + + img + width: 100% + +.bcf--import-listing + margin-bottom: 50px + +table.attributes-table + td,th + padding: 5px + text-align: center diff --git a/modules/bcf/app/controllers/bcf/base_controller.rb b/modules/bcf/app/controllers/bcf/base_controller.rb new file mode 100644 index 00000000000..52fe67b391a --- /dev/null +++ b/modules/bcf/app/controllers/bcf/base_controller.rb @@ -0,0 +1,4 @@ +module ::Bcf + class BaseController < ApplicationController + end +end diff --git a/modules/bcf/app/controllers/bcf/linked_issues_controller.rb b/modules/bcf/app/controllers/bcf/linked_issues_controller.rb new file mode 100644 index 00000000000..dd9a233c1b7 --- /dev/null +++ b/modules/bcf/app/controllers/bcf/linked_issues_controller.rb @@ -0,0 +1,88 @@ +module ::Bcf + class LinkedIssuesController < BaseController + include PaginationHelper + + before_action :find_project_by_project_id + before_action :authorize + + before_action :check_file_param, only: %i[prepare_import] + before_action :get_persisted_file, only: %i[perform_import] + before_action :persist_file, only: %i[prepare_import] + + before_action :build_importer, only: %i[prepare_import perform_import] + + before_action :get_issue_type + + menu_item :bcf + + def index + @issues = ::Bcf::Issue.in_project(@project) + .with_markup + .includes(:comments, :work_package, viewpoints: :attachments) + .page(page_param) + .per_page(per_page_param) + end + + def import; end + + def prepare_import + @bcf_file = params[:bcf_file] + + begin + @listing = @importer.get_extractor_list! @bcf_file.path + @issues = ::Bcf::Issue + .with_markup + .includes(work_package: %i[status priority assigned_to]) + .where(uuid: @listing.map { |e| e[:uuid] }) + rescue StandardError => e + flash[:error] = I18n.t('bcf.bcf_xml.import_failed', error: e.message) + redirect_to action: :index + end + end + + def perform_import + begin + result = @importer.import! @bcf_attachment.local_path + flash[:notice] = I18n.t('bcf.bcf_xml.import_successful', count: result) + rescue StandardError => e + flash[:error] = I18n.t('bcf.bcf_xml.import_failed', error: e.message) + end + + @bcf_attachment.destroy + redirect_to action: :index + end + + private + + def build_importer + @importer = ::OpenProject::Bcf::BcfXml::Importer.new @project, current_user: current_user + end + + def get_issue_type + @issue_type = @project.types.find_by(name: 'Issue [BCF]') + end + + def get_persisted_file + @bcf_attachment = Attachment.find_by! id: session[:bcf_file_id], author: current_user + rescue ActiveRecord::RecordNotFound + render_404 'BCF file not found' + end + + def persist_file + file = params[:bcf_file] + @bcf_attachment = Attachment.create! file: file, description: file.original_filename, author: current_user + session[:bcf_file_id] = @bcf_attachment.id + rescue StandardError => e + flash[:error] = "Failed to persist BCF file: #{e.message}" + redirect_to action: :index + end + + def check_file_param + path = params[:bcf_file]&.path + unless path && File.readable?(path) + flash[:error] = I18n.t('bcf.bcf_xml.import_failed', error: 'File missing or not readable') + redirect_to action: :import + end + end + end +end diff --git a/modules/bcf/app/models/bcf.rb b/modules/bcf/app/models/bcf.rb new file mode 100644 index 00000000000..f5acb9ac1b4 --- /dev/null +++ b/modules/bcf/app/models/bcf.rb @@ -0,0 +1,5 @@ +module Bcf + def self.table_name_prefix + 'bcf_' + end +end diff --git a/modules/bcf/app/models/bcf/comment.rb b/modules/bcf/app/models/bcf/comment.rb new file mode 100644 index 00000000000..579cf3c32df --- /dev/null +++ b/modules/bcf/app/models/bcf/comment.rb @@ -0,0 +1,14 @@ +module Bcf + class Comment < ActiveRecord::Base + include InitializeWithUuid + + belongs_to :journal + belongs_to :issue, foreign_key: :issue_id, class_name: "Bcf::Issue" + + validates_presence_of :uuid + + def self.has_uuid?(uuid) + where(uuid: uuid).exists? + end + end +end diff --git a/modules/bcf/app/models/bcf/initialize_with_uuid.rb b/modules/bcf/app/models/bcf/initialize_with_uuid.rb new file mode 100644 index 00000000000..b7a406e6a28 --- /dev/null +++ b/modules/bcf/app/models/bcf/initialize_with_uuid.rb @@ -0,0 +1,17 @@ +module Bcf + + ## + # Module to set an initial UUID on the model + # whenever it is created + module InitializeWithUuid + extend ActiveSupport::Concern + + included do + after_initialize :set_initial_uuid, if: :new_record? + end + + def set_initial_uuid + self.uuid ||= SecureRandom.uuid + end + end +end diff --git a/modules/bcf/app/models/bcf/issue.rb b/modules/bcf/app/models/bcf/issue.rb new file mode 100644 index 00000000000..ccf06c94aa5 --- /dev/null +++ b/modules/bcf/app/models/bcf/issue.rb @@ -0,0 +1,33 @@ +module Bcf + class Issue < ActiveRecord::Base + include InitializeWithUuid + + belongs_to :work_package + belongs_to :project + + class << self + def in_project(project) + where(project_id: project.try(:id) || project) + end + + def with_markup + select '*', + extract_xml('/Markup/Topic/Title/text()', 'title'), + extract_xml('/Markup/Topic/Description/text()', 'description'), + extract_xml('/Markup/Topic/Priority/text()', 'priority_text'), + extract_xml('/Markup/Topic/@TopicStatus', 'status_text'), + extract_xml('/Markup/Topic/AssignedTo/text()', 'assignee_text'), + extract_xml('/Markup/Topic/DueDate/text()', 'due_date_text') + end + + private + + def extract_xml(path, as) + "(xpath('#{path}', markup))[1] AS #{as}" + end + end + + has_many :viewpoints, foreign_key: :issue_id, class_name: "Bcf::Viewpoint" + has_many :comments, foreign_key: :issue_id, class_name: "Bcf::Comment" + end +end diff --git a/modules/bcf/app/models/bcf/viewpoint.rb b/modules/bcf/app/models/bcf/viewpoint.rb new file mode 100644 index 00000000000..726fa12d69a --- /dev/null +++ b/modules/bcf/app/models/bcf/viewpoint.rb @@ -0,0 +1,26 @@ +module Bcf + class Viewpoint < ActiveRecord::Base + include InitializeWithUuid + + acts_as_attachable view_permission: :view_linked_issues, + delete_permission: :manage_bcf, + add_on_new_permission: :manage_bcf, + add_on_persisted_permission: :manage_bcf + + def self.has_uuid?(uuid) + where(uuid: uuid).exists? + end + + belongs_to :issue, foreign_key: :issue_id, class_name: "Bcf::Issue" + delegate :project, :project_id, to: :issue, allow_nil: true + + def snapshot + attachments.find_by_description('snapshot') + end + + def snapshot=(file) + snapshot&.destroy + attach_files('first' => { 'file' => file, 'description' => 'snapshot' }) + end + end +end diff --git a/modules/bcf/app/views/bcf/linked_issues/import.html.erb b/modules/bcf/app/views/bcf/linked_issues/import.html.erb new file mode 100644 index 00000000000..69ec5042ac9 --- /dev/null +++ b/modules/bcf/app/views/bcf/linked_issues/import.html.erb @@ -0,0 +1,30 @@ +<%= toolbar title: t('bcf.bcf_xml.import_title') %> + +<% unless @issue_type %> +
    +
    +

    + <%= t('bcf.bcf_xml.type_not_active') %> + <%= link_to t(:label_project_settings), settings_project_path(@project, tab: :types) %> +

    +
    +
    +<% end %> + +<%= form_tag({ action: :prepare_import }, multipart: true, method: :post) do %> + +
    + <%= styled_label_tag 'bcf_file', t('bcf.bcf_xml.xml_file') %> +
    + <%= styled_file_field_tag :bcf_file, required: true %> +
    +
    +

    <%= t('bcf.bcf_xml.import.description') %>

    +
    +
    + + <%= submit_tag t('bcf.bcf_xml.import.button_prepare'), class: 'button -highlight' %> + <%= link_to t(:button_cancel), + { action: :index }, + class: 'button' %> +<% end %> diff --git a/modules/bcf/app/views/bcf/linked_issues/index.html.erb b/modules/bcf/app/views/bcf/linked_issues/index.html.erb new file mode 100644 index 00000000000..7af363a2e0e --- /dev/null +++ b/modules/bcf/app/views/bcf/linked_issues/index.html.erb @@ -0,0 +1,56 @@ +<%= stylesheet_link_tag 'bcf/bcf.css' %> + + +<%= toolbar title: t('bcf.linked_issues'), html: {class: '-with-dropdown'} do %> + +
  • + <%= link_to({ action: 'import' }, + title: I18n.t(:label_import), + class: 'button import-bcf-button') do %> + <%= op_icon('button--icon icon-import') %> + <%= l(:label_import) %> + <% end %> +
  • + <% if @issue_type && @issues.present? %> +
  • + <% query = { f: [filter_object('type', '=', @issue_type.id)] } %> + <%= link_to(project_work_packages_with_query_path(@project, query, format: :bcf), + title: t('bcf.bcf_xml.export'), + class: 'button export-bcf-button') do %> + <%= op_icon('button--icon icon-export') %> + <%= t('bcf.bcf_xml.export') %> + <% end %> +
  • + <% end %> +<% end %> + +<% if @issues.present? %> +
    + <% @issues.each do |issue| %> + <% status_id = issue.work_package&.status_id %> + <% hl_classname = status_id.present? ? "__hl_row_status_#{status_id}" : '' %> +
    +

    + <%= issue.title %> +
    + <% if issue.work_package %> + <%= link_to_work_package(issue.work_package) %> +
    + <% end %> +

    + <% if issue.viewpoints.empty? %> +

    (No viewpoints)

    + <% end %> + <% issue.viewpoints.each do |vp| %> + + <% end %> +
    + <% end %> +
    +<% else %> + <%= no_results_box %> +<% end %> + +<%= pagination_links_full @issues %> diff --git a/modules/bcf/app/views/bcf/linked_issues/prepare_import.html.erb b/modules/bcf/app/views/bcf/linked_issues/prepare_import.html.erb new file mode 100644 index 00000000000..ab797ee56cf --- /dev/null +++ b/modules/bcf/app/views/bcf/linked_issues/prepare_import.html.erb @@ -0,0 +1,60 @@ +<%= stylesheet_link_tag 'bcf/bcf.css' %> +<%= toolbar title: t('bcf.bcf_xml.import_title') %> + +

    <%= t('bcf.bcf_xml.import.num_issues_found', x_bcf_issues: t('bcf.x_bcf_issues', count: @listing.count)) %>

    + +<% @listing.each do |entry| %> + <% issue = @issues.detect { |bcf| bcf.uuid == entry[:uuid] } %> + +
    +

    <%= entry[:title] %>

    + + + + + + + + + + + + + + + + <% if issue && issue.work_package %> + + <% else %> + + <% end %> + + + + + + + + + <% if issue&.work_package %> + + + + + + + + + <% end %> + +
    Work packageTitleStatusPriorityAssigneeDue date# Viewpoints# Comments
    <%= link_to_work_package issue.work_package %>(will be created)<%= entry[:title] || '-' %><%= entry[:status] || '-' %><%= entry[:priority] || '-' %><%= entry[:assignee] || '-' %><%= format_date(entry[:due_date]) || '-' %><%= entry[:viewpoint_count] %><%= entry[:comments_count] %>
    <%= issue.work_package.subject %><%= issue.work_package.status.name %><%= issue.work_package.priority.name %><%= issue.work_package.assigned_to&.name %><%= format_date(issue.work_package.due_date) %><%= issue.viewpoints.count %><%= issue.comments.count %>
    +
    +<% end %> + +<%= form_tag({ action: :perform_import }, multipart: true, method: :post) do %> +

    <%= t('bcf.bcf_xml.import.perform_description') %>

    + <%= 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" } }