CKEditor5 config and bundle for OP

pull/6015/head
Oliver Günther 7 years ago
parent ea45a2194d
commit 88a5e787ce
No known key found for this signature in database
GPG Key ID: 88872239EB414F99
  1. 1
      .gitignore
  2. 4
      .pkgr.yml
  3. 11
      Dockerfile
  4. 6
      app/assets/stylesheets/content/_headings.sass
  5. 1
      app/assets/stylesheets/content/_index.sass
  6. 19
      app/assets/stylesheets/content/editor/_ckeditor.sass
  7. 2
      app/assets/stylesheets/content/work_packages/inplace_editing/_edit_fields.sass
  8. 1
      app/helpers/application_helper.rb
  9. 1
      app/views/layouts/base.html.erb
  10. 6
      app/views/layouts/user_mailer.html.erb
  11. 10
      app/views/wiki/edit.html.erb
  12. 1
      config/initializers/assets.rb
  13. 1
      frontend/app/components/angular/angular-injector-bridge.functions.ts
  14. 117
      frontend/app/components/ckeditor/op-ckeditor-form.component.ts
  15. 17
      frontend/app/components/work-packages/work-package-comment/work-package-comment.directive.test.js
  16. 12
      frontend/app/components/work-packages/work-package-comment/work-package-comment.directive.ts
  17. 4
      frontend/app/components/work-packages/work-package-comment/wp-comment-field.module.ts
  18. 22
      frontend/app/components/work-packages/wp-editor-field/wp-editor-field.component.html
  19. 145
      frontend/app/components/work-packages/wp-editor-field/wp-editor-field.component.ts
  20. 11
      frontend/app/components/work-packages/wp-single-view/wp-single-view.directive.html
  21. 1
      frontend/app/components/wp-edit-form/single-view-edit-context.ts
  22. 10
      frontend/app/components/wp-edit-form/work-package-edit-field-handler.ts
  23. 2
      frontend/app/components/wp-edit-form/work-package-edit-form.ts
  24. 12
      frontend/app/components/wp-edit/field-types/wp-edit-markdown-field.directive.html
  25. 58
      frontend/app/components/wp-edit/field-types/wp-edit-wiki-textarea-field.module.ts
  26. 4
      frontend/app/components/wp-edit/wp-edit-field/wp-edit-field.module.ts
  27. 3
      frontend/app/components/wp-field/wp-field.module.ts
  28. 80
      frontend/ckeditor/ckeditor.ts
  29. 40
      frontend/ckeditor/plugins/op-image-upload/op-image-upload.ts
  30. 61
      frontend/ckeditor/plugins/op-image-upload/op-upload-adadpter.ts
  31. 41
      frontend/ckeditor/plugins/op-table/CHANGELOG.md
  32. 4
      frontend/ckeditor/plugins/op-table/CONTRIBUTING.md
  33. 23
      frontend/ckeditor/plugins/op-table/LICENSE.md
  34. 19
      frontend/ckeditor/plugins/op-table/README.md
  35. 41
      frontend/ckeditor/plugins/op-table/package-lock.json
  36. 58
      frontend/ckeditor/plugins/op-table/package.json
  37. 49
      frontend/ckeditor/plugins/op-table/src/op-table.js
  38. 13
      frontend/ckeditor/postcss.config.js
  39. 29
      frontend/ckeditor/tsconfig.json
  40. 4168
      frontend/npm-shrinkwrap.json
  41. 2
      frontend/package.json
  42. 3
      frontend/tsconfig.json
  43. 147
      frontend/webpack-ckeditor-config.js
  44. 4
      frontend/webpack-main-config.js
  45. 20
      frontend/webpack-vendors-config.js
  46. 4
      frontend/webpack.config.js
  47. 2
      lib/open_project/journal_formatter/diff.rb
  48. 16
      lib/open_project/text_formatting/filters/markdown_filter.rb
  49. 11
      lib/open_project/text_formatting/filters/pattern_matcher_filter.rb
  50. 8
      lib/open_project/text_formatting/filters/sanitization_filter.rb
  51. 32
      lib/open_project/text_formatting/formatters/base.rb
  52. 38
      lib/open_project/text_formatting/formatters/markdown/formatter.rb
  53. 8
      lib/open_project/text_formatting/formatters/markdown/helper.rb
  54. 189
      lib/open_project/text_formatting/formatters/markdown/textile_converter.rb
  55. 25
      lib/open_project/text_formatting/formatters/plain/formatter.rb
  56. 524
      lib/open_project/text_formatting/formatters/textile/formatter.rb
  57. 514
      lib/open_project/text_formatting/formatters/textile/legacy_text_formatting.rb
  58. 143
      lib/open_project/text_formatting/formatters/textile/redcloth_wrapper.rb
  59. 16
      lib/open_project/text_formatting/renderer.rb
  60. 46
      lib/tasks/markdown.rake
  61. 2
      spec/features/security/angular_xss_spec.rb
  62. 10
      spec/features/work_packages/details/activity_comments_spec.rb
  63. 2
      spec/features/work_packages/details/custom_fields/custom_field_spec.rb
  64. 2
      spec/features/work_packages/details/inplace_editor/description_editor_spec.rb
  65. 4
      spec/features/work_packages/details/inplace_editor/shared_examples.rb
  66. 2
      spec/features/work_packages/table/inline_create/parallel_creation_spec.rb
  67. 9
      spec/features/work_packages/tabs/activity_tab_spec.rb
  68. 12
      spec/lib/open_project/text_formatting/plain_spec.rb
  69. 25
      spec/lib/open_project/text_formatting/textile/textile_formatting_spec.rb
  70. 0
      spec/lib/open_project/text_formatting/textile/textile_spec.rb
  71. 26
      spec/lib/open_project/text_formatting/wiki_formatting_spec.rb
  72. 4
      spec/support/pages/abstract_work_package.rb
  73. 49
      spec/support/work_packages/work_package_editor_field.rb
  74. 54
      spec_legacy/functional/user_mailer_spec.rb

1
.gitignore vendored

@ -49,6 +49,7 @@ npm-debug.log*
/.project
/.loadpath
/app/assets/javascripts/bundles/*.*
/app/assets/javascripts/editor/*
/app/assets/javascripts/locales/*.*
/config/additional_environment.rb
/config/configuration.yml

@ -4,6 +4,7 @@ targets:
debian-8: &debian8
build_dependencies:
- libsqlite3-dev
- cmake
debian-9:
<<: *debian8
ubuntu-14.04:
@ -13,12 +14,15 @@ targets:
centos-7:
dependencies:
- epel-release
- cmake
sles-11:
build_dependencies:
- sqlite3-devel
- cmake
sles-12:
build_dependencies:
- sqlite3-devel
- cmake
before_precompile: "packaging/setup"
crons:
- packaging/cron/openproject-hourly-tasks

@ -1,7 +1,14 @@
FROM ruby:2.4-stretch
ENV NODE_VERSION="7.7.2"
ENV BUNDLER_VERSION="1.11.2"
ENV NODE_VERSION="8.9.1"
ENV BUNDLER_VERSION="1.16.0"
# Install cmake for gems
# (commonmarker)
USER root
RUN apt-get update -qq && \
DEBIAN_FRONTEND=noninteractive apt-get install -y \
cmake
# install node + npm
RUN curl https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.gz | tar xzf - -C /usr/local --strip-components=1

@ -26,12 +26,6 @@
// See docs/COPYRIGHT.rdoc for more details.
//++
body.controller-work_packages.action-show,
body.controller-work_packages.action-update
#content
h2
padding-right: 340px
h1
color: $h1-font-color
font-weight: bold

@ -64,3 +64,4 @@
@import content/custom_actions
@import content/menus/_project_autocompletion
@import content/editor/ckeditor

@ -0,0 +1,19 @@
// Wrapper for inline text editor
.op-ckeditor-element
min-height: 50px
border: 1px solid #bfbfbf !important
&.ck-editor__editable_inline
padding-left: 2px !important
// Wrapper for full text element
.op-ckeditor--wrapper
// Borders for the main editor
.ck-editor__main
border: 1px solid #bfbfbf
margin-bottom: 2rem
// Min height for the editable section
.ck-editor__editable
min-height: 20vh

@ -36,7 +36,7 @@
p
word-wrap: break-word
margin-bottom: 0
// margin-bottom: 0
.read-value--html
*

@ -36,6 +36,7 @@ module ApplicationHelper
include OpenProject::ObjectLinking
include OpenProject::SafeParams
include I18n
include ERB::Util
include Redmine::I18n
include HookHelper
include IconsHelper

@ -50,6 +50,7 @@ See docs/COPYRIGHT.rdoc for more details.
<%= csrf_meta_tags %>
<%= render 'common/favicons' %>
<%= stylesheet_link_tag 'openproject', media: "all" %>
<%= javascript_include_tag 'editor/openproject-ckeditor' %>
<%= javascript_include_tag 'application' %>
<%= javascript_include_tag "locales/#{I18n.locale}" %>
<!-- project specific tags -->

@ -63,5 +63,11 @@ See docs/COPYRIGHT.rdoc for more details.
</style>
</head>
<body>
<span class="header"><%= OpenProject::TextFormatting::Renderer.format_text(Setting.localized_emails_header) %></span>
<%= call_hook(:view_layouts_mailer_html_before_content, self.assigns) %>
<%= yield %>
<%= call_hook(:view_layouts_mailer_html_after_content, self.assigns) %>
<hr />
<span class="footer"><%= OpenProject::TextFormatting::Renderer.format_text(Setting.localized_emails_footer) %></span>
</body>
</html>

@ -33,13 +33,9 @@ See docs/COPYRIGHT.rdoc for more details.
<%= error_messages_for 'content' %>
<div class="attributes-group">
<div class="attributes-group--header">
<div class="attributes-group--header-container">
<h3 class="attributes-group--header-text"><%= WikiPage.human_attribute_name(:text) %></h3>
</div>
</div>
<%= f.text_area :text, :cols => 100, :rows => 25, :class => 'wiki-edit op-auto-complete', :accesskey => accesskey(:edit) %>
<%= f.text_area :text, cols: 100, rows: 25, class: 'wiki-edit op-auto-complete', hidden: true, accesskey: accesskey(:edit) %>
</div>
<op-ckeditor-form textarea-selector="#content_text"></op-ckeditor-form>
<div class="form--field">
<%= f.text_field :comments, size: 120 %>
@ -52,8 +48,6 @@ See docs/COPYRIGHT.rdoc for more details.
<%= link_to t(:button_cancel),
{ controller: '/wiki', action: 'show', project_id: @project, id: @page },
class: 'button' %>
<%= preview_link preview_project_wiki_path(@project, @page), 'wiki_form-preview' %>
<%= wikitoolbar_for 'content_text' %>
<% end %>
<div id="preview"></div>
<% content_for :header_tags do %>

@ -25,5 +25,6 @@ OpenProject::Application.configure do
select_list_move.js
types_checkboxes.js
work_packages.js
editor/openproject-ckeditor.js
)
end

@ -1,4 +1,5 @@
/**
* Returns the currently bootstrapped injector from the application.
* Not applicable until after the application bootstrapping is done.

@ -0,0 +1,117 @@
// -- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See doc/COPYRIGHT.rdoc for more details.
// ++
import IAugmentedJQuery = angular.IAugmentedJQuery;
import {IDialogService} from 'ng-dialog';
import {IDialogScope} from 'ng-dialog';
import {opUiComponentsModule} from '../../angular-modules';
export interface ICkeditorInstance {
getData():string;
setData(content:string):void;
}
export interface ICkeditorStatic {
create(el:HTMLElement):Promise<ICkeditorInstance>;
}
declare global {
interface Window {
BalloonEditor:ICkeditorStatic;
ClassicEditor:ICkeditorStatic;
}
}
const ckEditorWrapperClass = 'op-ckeditor--wrapper';
const ckEditorReplacementClass = '__op_ckeditor_replacement_container';
export class OpCkeditorFormComponent {
public textareaSelector:string;
// Which template to include
public ckeditor:any;
public formElement:JQuery;
public wrappedTextArea:JQuery;
// Remember if the user changed
public changed:boolean = false;
public inFlight:boolean = false;
public text:any;
constructor(protected $element:ng.IAugmentedJQuery,
protected $timeout:ng.ITimeoutService,
protected ConfigurationService:any,
protected I18n:op.I18n) {
}
public $onInit() {
this.formElement = this.$element.closest('form');
this.wrappedTextArea = this.formElement.find(this.textareaSelector);
const wrapper = this.$element.find(`.${ckEditorReplacementClass}`);
window.ClassicEditor
.create(wrapper[0])
.then(this.setup.bind(this))
.catch((error:any) => {
console.error(error);
});
}
public $onDestroy() {
this.formElement.off('submit.ckeditor');
}
public setup(editor:ICkeditorInstance) {
this.ckeditor = editor;
const rawValue = this.wrappedTextArea.val();
if (rawValue) {
editor.setData(rawValue);
}
// Listen for form submission to set textarea content
this.formElement.on('submit.ckeditor', () => {
const value = this.ckeditor.getData();
this.wrappedTextArea.val(value);
// Continue with submission
return true;
});
}
}
opUiComponentsModule.component('opCkeditorForm', {
template: `<div class="${ckEditorWrapperClass}"><div class="${ckEditorReplacementClass}"></div>`,
controller: OpCkeditorFormComponent,
controllerAs: '$ctrl',
bindings: {
textareaSelector: '@'
}
});

@ -132,23 +132,6 @@ describe('workPackageCommentDirectiveTest', function() {
var readvalue = commentSection.find('.inplace-edit--read-value > span');
expect(readvalue.text().trim()).to.equal('trans_title');
});
describe('when clicking the inplace edit', function() {
beforeEach(function() {
commentSection.find('.inplace-editing--trigger-link').click();
});
it('does not allow sending comment with an empty message', function() {
var saveButton = commentSection.find('.inplace-edit--control--save');
var commentField = commentSection.find('textarea').click();
expect(saveButton.attr('disabled')).to.eq('disabled');
commentField.val('a useful comment');
commentField.trigger('change');
expect(saveButton.attr('disabled')).to.be.undefined;
});
});
});
});
});

@ -69,7 +69,7 @@ export class CommentFieldDirectiveController {
$scope.$on('workPackage.comment.quoteThis', (evt, quote) => {
this.resetField(quote);
this.editing = true;
this.activate();
this.$element.find('.work-packages--activity--add-comment')[0].scrollIntoView();
});
}
@ -92,10 +92,15 @@ export class CommentFieldDirectiveController {
public activate(withText?:string) {
this._forceFocus = true;
this.resetField(withText);
this.editing = true;
this.$timeout(() => this.$element.find('.wp-inline-edit--field').focus());
this.$timeout(() => {
if (!this.field) {
this.resetField(withText);
}
this.field.$onInit(this.$element);
});
}
public get project() {
@ -108,6 +113,7 @@ export class CommentFieldDirectiveController {
}
public handleUserSubmit() {
this.field.onSubmit();
if (this.field.isBusy || this.field.isEmpty()) {
return;
}

@ -52,6 +52,10 @@ export class WorkPackageCommentField extends WikiTextareaEditField {
return true;
}
public isEmpty():boolean {
return false;
}
public initializeFieldValue(withText?:string):void {
if (!withText) {
this.rawValue = '';

@ -0,0 +1,22 @@
<div class="textarea-wrapper op-ckeditor-wrapper" ng-class="{'-preview': vm.field.isPreview}">
<div class="op-ckeditor-element" ng-class="{ '-active': $ctrl.isChanged }">
</div>
<div class="inplace-edit--dashboard">
<div class="inplace-edit--controls" ng-show="$ctrl.isChanged || $ctrl.inFlight">
<accessible-by-keyboard execute="$ctrl.submit()"
link-title="{{ vm.saveTitle }}"
is-disabled="$ctrl.inFlight"
ng-disabled="$ctrl.inFlight"
class="inplace-edit--control inplace-edit--control--save">
<op-icon icon-classes="icon-checkmark" icon-title="{{ ::vm.saveTitle }}"></op-icon>
</accessible-by-keyboard>
<accessible-by-keyboard execute="$ctrl.reset()"
link-title="{{ vm.cancelTitle }}"
ng-disabled="$ctrl.inFlight"
is-disabled="$ctrl.inFlight"
class="inplace-edit--control inplace-edit--control--cancel">
<op-icon icon-classes="icon-close" icon-title="{{ vm.cancelTitle }}"></op-icon>
</accessible-by-keyboard>
</div>
</div>
</div>

@ -0,0 +1,145 @@
// -- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See doc/COPYRIGHT.rdoc for more details.
// ++
import IAugmentedJQuery = angular.IAugmentedJQuery;
import {IDialogService} from 'ng-dialog';
import {IDialogScope} from 'ng-dialog';
import {opUiComponentsModule} from '../../../angular-modules';
import {HelpTextResourceInterface} from '../../api/api-v3/hal-resources/help-text-resource.service';
import {HelpTextDmService} from '../../api/api-v3/hal-resource-dms/help-text-dm.service';
import {opWorkPackagesModule} from './../../../angular-modules';
import {WorkPackageChangeset} from './../../wp-edit-form/work-package-changeset';
import {WorkPackageResourceInterface} from './../../api/api-v3/hal-resources/work-package-resource.service';
import { WorkPackageEditFieldGroupController } from 'app/components/wp-edit/wp-edit-field/wp-edit-field-group.directive';
export class WorkPackageEditorFieldController {
public wpEditFieldGroup:WorkPackageEditFieldGroupController;
public workPackage:WorkPackageResourceInterface;
public attribute:string;
public wrapperClasses:string;
// Which template to include
public format:string;
public ckeditor:any;
// Remember if the user changed
public changed:boolean = false;
public inFlight:boolean = false;
public text:any;
constructor(protected $element:ng.IAugmentedJQuery,
protected $timeout:ng.ITimeoutService,
protected ConfigurationService:any,
protected I18n:op.I18n) {
this.text = {
saveTitle: 'Save',
cancelTitle: 'Cancel'
};
// if(ConfigurationService.text_formatting == 'markdown') {
this.format = 'markdown';
// } else {
// }
}
public $onInit() {
const element = this.$element.find('.op-ckeditor-element');
(window as any).BalloonEditor
.create(element[0])
.then((editor:any) => {
this.ckeditor = editor;
if (this.rawValue) {
this.reset();
}
})
.catch((error:any) => {
console.error(error);
});
}
public submit() {
this.inFlight = true;
this.value = this.ckeditor.getData();
this.wpEditFieldGroup.saveWorkPackage().then(() => {
this.reset();
})
.catch(() => {
this.reset();
});
}
public reset() {
this.ckeditor.setData(this.rawValue);
this.$timeout(() => {
this.changed = false;
this.inFlight = false;
});
}
public get isInitialized() {
return !!this.ckeditor;
}
public get value() {
return this.changeset.value(this.attribute);
}
public get rawValue() {
if (this.value && this.value.raw) {
return this.value.raw;
} else {
return '';
}
}
public set value(value:any) {
this.changeset.setValue(this.attribute, { raw: value });
}
public get changeset():WorkPackageChangeset {
return this.wpEditFieldGroup.form.changeset;
}
}
opWorkPackagesModule.component('wpEditorField', {
templateUrl: '/components/work-packages/wp-editor-field/wp-editor-field.component.html',
controller: WorkPackageEditorFieldController,
require: {
wpEditFieldGroup: '^wpEditFieldGroup'
},
controllerAs: '$ctrl',
bindings: {
workPackage: '<',
attribute: '<',
wrapperClasses: '@'
}
});

@ -65,12 +65,11 @@
</div>
</div>
<div class="single-attribute wiki work-packages--details--description">
<wp-edit-field field-name="'description'"
work-package-id="$ctrl.workPackage.id"
wrapper-classes="'-no-label'"
display-placeholder="$ctrl.I18n.t('js.work_packages.placeholders.description')"
wp-attachments-formattable>
</wp-edit-field>
<wp-edit-field field-name="'description'"
work-package-id="$ctrl.workPackage.id"
wrapper-classes="'-no-label'"
display-placeholder="$ctrl.I18n.t('js.work_packages.placeholders.description')">
</wp-edit-field>
</div>
</div>

@ -99,6 +99,7 @@ export class SingleViewEditContext implements WorkPackageEditContext {
ctrl.editContainer.show();
// Assure the element is visible
this.$timeout(() => {
field.$onInit(container);
resolve(fieldHandler);
});
})

@ -40,6 +40,7 @@ export class WorkPackageEditFieldHandler {
// Injections
public FocusHelper:any;
public ConfigurationService:any;
public $q:ng.IQService;
public I18n:op.I18n;
// Scope the field has been rendered in
@ -58,7 +59,7 @@ export class WorkPackageEditFieldHandler {
public field:EditField,
public element:JQuery,
public withErrors:string[]) {
$injectFields(this, 'I18n', 'ConfigurationService', 'FocusHelper');
$injectFields(this, 'I18n', '$q', 'ConfigurationService', 'FocusHelper');
this.editContext = form.editContext;
this.schemaName = field.name;
@ -96,10 +97,13 @@ export class WorkPackageEditFieldHandler {
/**
* Handle a user submitting the field (e.g, ng-change)
*/
public handleUserSubmit() {
public handleUserSubmit():ng.IPromise<any> {
if (!this.form.editMode) {
this.form.submit();
this.field.onSubmit();
return this.form.submit();
}
return this.$q.resolve();
}
/**

@ -192,7 +192,9 @@ export class WorkPackageEditForm {
// Reset old error notifcations
this.errorsPerAttribute = {};
// Notify all fields of upcoming save
const openFields = _.keys(this.activeFields);
_.each(this.activeFields, (handler:WorkPackageEditFieldHandler) => handler.field.onSubmit());
this.changeset.save()
.then(savedWorkPackage => {

@ -0,0 +1,12 @@
<div class="textarea-wrapper">
<div class="op-ckeditor-wrapper op-ckeditor-element">
</div>
<wp-edit-field-controls ng-show="!vm.inEditMode"
field-controller="vm"
on-save="vm.handleUserSubmit()"
on-cancel="vm.handleUserCancel()"
save-title="{{ vm.field.text.save }}"
cancel-title="{{ vm.field.text.cancel }}">
</wp-edit-field-controls>
</div>
</div>

@ -27,14 +27,14 @@
// ++
import {EditField} from '../wp-edit-field/wp-edit-field.module';
import {WorkPackageResource} from '../../api/api-v3/hal-resources/work-package-resource.service';
import {$injectFields, $injectNow} from '../../angular/angular-injector-bridge.functions';
import {$injectFields} from '../../angular/angular-injector-bridge.functions';
import {TextileService} from './../../common/textile/textile-service';
import {WorkPackageEditFieldHandler} from 'core-components/wp-edit-form/work-package-edit-field-handler';
export class WikiTextareaEditField extends EditField {
// Template
public template:string = '/components/wp-edit/field-types/wp-edit-wiki-textarea-field.directive.html';
public template:string = '/components/wp-edit/field-types/wp-edit-markdown-field.directive.html';
// Dependencies
protected $sce:ng.ISCEService;
@ -49,6 +49,9 @@ export class WikiTextareaEditField extends EditField {
public previewHtml:string;
public text:Object;
// CKEditor instance
public ckeditor:any;
protected initialize() {
$injectFields(this, '$sce', '$http', 'textileService', '$timeout', 'I18n');
@ -59,9 +62,48 @@ export class WikiTextareaEditField extends EditField {
};
}
public onSubmit() {
if (this.ckeditor) {
this.rawValue = this.ckeditor.getData();
}
}
public get isInitialized() {
return !!this.ckeditor;
}
public $onInit(container:JQuery) {
const element = container.find('.op-ckeditor-element');
(window as any).BalloonEditor
.create(element[0])
.then((editor:any) => {
editor.config['openProject'] = {
context: this.resource,
element: element
};
this.ckeditor = editor;
if (this.rawValue) {
this.reset();
}
element.focus();
})
.catch((error:any) => {
console.error(error);
});
}
public reset() {
this.ckeditor.setData(this.rawValue);
}
public get rawValue() {
const formatted = this.value;
return _.get(formatted, 'raw', '');
if (this.value && this.value.raw) {
return this.value.raw;
} else {
return '';
}
}
public set rawValue(val:string) {
@ -73,7 +115,11 @@ export class WikiTextareaEditField extends EditField {
}
public isEmpty():boolean {
return !(this.value && this.value.raw);
if (this.isInitialized) {
return this.ckeditor.getData() === '';
} else {
return !(this.value && this.value.raw);
}
}
public submitUnlessInPreview(form:any) {

@ -42,6 +42,10 @@ export class EditField extends Field {
this.initialize();
}
public onSubmit() {
}
public get inFlight() {
return this.changeset.inFlight;
}

@ -27,11 +27,14 @@
// ++
import {HalResource} from '../api/api-v3/hal-resources/hal-resource.service';
import {WorkPackageEditFieldHandler} from 'core-components/wp-edit-form/work-package-edit-field-handler';
export class Field {
public static type:string;
public static $injector:ng.auto.IInjectorService;
public $onInit(container:JQuery) {}
public get displayName():string {
return this.schema.name || this.name;
}

@ -0,0 +1,80 @@
const ClassicEditor = (require('./ckeditor5/packages/ckeditor5-editor-classic/src/classiceditor') as any).default;
const BalloonEditor = (require('./ckeditor5/packages/ckeditor5-editor-balloon/src/ballooneditor') as any).default;
const EssentialsPlugin = (require('./ckeditor5/packages/ckeditor5-essentials/src/essentials') as any).default;
const AutoformatPlugin = (require('./ckeditor5/packages/ckeditor5-autoformat/src/autoformat') as any).default;
const BoldPlugin = (require('./ckeditor5/packages/ckeditor5-basic-styles/src/bold') as any).default;
const ItalicPlugin = (require('./ckeditor5/packages/ckeditor5-basic-styles/src/italic') as any).default;
const BlockquotePlugin = (require('./ckeditor5/packages/ckeditor5-block-quote/src/blockquote') as any).default;
const HeadingPlugin = (require('./ckeditor5/packages/ckeditor5-heading/src/heading') as any).default;
const ImagePlugin = (require('./ckeditor5/packages/ckeditor5-image/src/image') as any).default;
const ImagecaptionPlugin = (require('./ckeditor5/packages/ckeditor5-image/src/imagecaption') as any).default;
const ImagestylePlugin = (require('./ckeditor5/packages/ckeditor5-image/src/imagestyle') as any).default;
const ImagetoolbarPlugin = (require('./ckeditor5/packages/ckeditor5-image/src/imagetoolbar') as any).default;
const LinkPlugin = (require('./ckeditor5/packages/ckeditor5-link/src/link') as any).default;
const ListPlugin = (require('./ckeditor5/packages/ckeditor5-list/src/list') as any).default;
const ParagraphPlugin = (require('./ckeditor5/packages/ckeditor5-paragraph/src/paragraph') as any).default;
// const GFMDataProcessor = (require('./ckeditor5/packages/ckeditor5-markdown-gfm/src/gfmdataprocessor') as any).default;
// import OPCommonMarkProcessor from './plugins/op-commonmark/op-commonmark';
const CommonMarkDataProcessor = (require('./plugins/ckeditor5-markdown-gfm/src/commonmarkdataprocessor') as any).default;
// import OpTableWidget from './plugins/op-table/src/op-table';
import OPImageUploadPlugin from './plugins/op-image-upload/op-image-upload';
function Markdown( editor:any ) {
editor.data.processor = new CommonMarkDataProcessor();
}
declare global {
var angular: any;
}
export class OPClassicEditor extends ClassicEditor {}
export class OPBalloonEditor extends BalloonEditor {}
(window as any).BalloonEditor = OPBalloonEditor;
(window as any).ClassicEditor = OPClassicEditor;
const config = {
plugins: [
// Markdown,
EssentialsPlugin,
AutoformatPlugin,
BoldPlugin,
ItalicPlugin,
BlockquotePlugin,
HeadingPlugin,
ImagePlugin,
ImagecaptionPlugin,
ImagestylePlugin,
ImagetoolbarPlugin,
LinkPlugin,
ListPlugin,
ParagraphPlugin,
// OPImageUploadPlugin
],
config: {
toolbar: [
'headings',
'bold',
'italic',
'link',
'bulletedList',
'numberedList',
'blockQuote',
'undo',
'redo'
],
image: {
toolbar: [
'imageStyleFull',
'imageStyleSide',
'|',
'imageTextAlternative'
]
}
}
};
(OPClassicEditor as any).build = config;
(OPBalloonEditor as any).build = config;

@ -0,0 +1,40 @@
const Plugin:any = (require('@ckeditor/ckeditor5-core/src/plugin') as any).default;
const Image:any = (require('@ckeditor/ckeditor5-image/src/image') as any).default;
const FileRepository:any = (require('@ckeditor/ckeditor5-upload/src/filerepository') as any).default;
const ImageUpload:any = (require('@ckeditor/ckeditor5-upload/src/imageupload') as any).default;
const ImageUploadEngine:any = (require('@ckeditor/ckeditor5-upload/src/imageuploadengine') as any).default;
import { OpenProjectUploadAdapter } from './op-upload-adadpter';
interface CkEditorInstance {
plugins:any;
}
interface IFileLoader {
file:File;
uploadTotal?:number;
uploaded?:number;
}
export default class OPImageUploadPlugin extends Plugin {
public editor:CkEditorInstance;
static get requires() {
return [
Image,
ImageUpload,
ImageUploadEngine,
FileRepository
];
}
init() {
this.editor.plugins.get( FileRepository ).createAdapter = (loader:IFileLoader) => {
return new OpenProjectUploadAdapter(loader as any, this.editor);
};
}
static get pluginName() {
return 'OpenProject Image Upload';
}
}

@ -0,0 +1,61 @@
import IFileLoader from './op-image-upload';
import {$injectFields} from 'core-components/angular/angular-injector-bridge.functions';
export class OpenProjectUploadAdapter {
// Injected service
public Upload:any;
// Upload instance
public uploader:any;
constructor(public loader:IFileLoader, public editor:any) {
// Save Loader instance to update upload progress.
this.loader = loader;
$injectFields(this, 'Upload');
}
public get uploadUrl() {
const config = this.editor.config.openProject;
return config.context.addAttachment.href;
}
public upload() {
const file = this.loader.file;
const metadata = {
description: file.description,
fileName: file.customName || file.name
};
// need to wrap the metadata into a JSON ourselves as ngFileUpload
// will otherwise break up the metadata into individual parts
const data = {
metadata: JSON.stringify(metadata),
file: this.loader.file
};
return this.uploader = this.performUpload(data, this.uploadUrl);
}
public performUpload(data:any, url:string) {
const uploader = this.Upload.upload({data, url});
uploader.progress((details:any) => {
var file = details.config.file || details.config.data.file;
if (details.lengthComputable) {
this.loader.uploaded = details.loaded;
this.loader.uploadTotal = details.total;
}
});
// Return srcset data for image
// https://ckeditor5.github.io/docs/nightly/ckeditor5/latest/api/module_upload_filerepository-Adapter.html#upload
return uploader.then((result:any) => {
return { default: result.data._links.downloadLocation.href };
});
}
abort() {
return this.uploader && this.uploader.abort();
}
}

@ -1,41 +0,0 @@
Changelog
=========
## [1.0.0-alpha.1](https://github.com/ckeditor/ckeditor5-block-quote/compare/v0.2.0...v1.0.0-alpha.1) (2017-10-03)
### Other changes
* Improved default blockquote styling so it does not overlap with floated images. Closes [#12](https://github.com/ckeditor/ckeditor5-block-quote/issues/12). ([fb09418](https://github.com/ckeditor/ckeditor5-block-quote/commit/fb09418))
## [0.2.0](https://github.com/ckeditor/ckeditor5-block-quote/compare/v0.1.1...v0.2.0) (2017-09-03)
### Features
* <kbd>Enter</kbd> in the block quote will scroll the viewport to the selection. See ckeditor/ckeditor5-engine#660. ([09dc740](https://github.com/ckeditor/ckeditor5-block-quote/commit/09dc740))
### Other changes
* Aligned the implementation to the new Command API (see https://github.com/ckeditor/ckeditor5-core/issues/88). ([627510a](https://github.com/ckeditor/ckeditor5-block-quote/commit/627510a))
### BREAKING CHANGES
* The command API has been changed.
## [0.1.1](https://github.com/ckeditor/ckeditor5-block-quote/compare/v0.1.0...v0.1.1) (2017-05-07)
### Bug fixes
* Block quote should not be applied to image's caption. Closes: [#10](https://github.com/ckeditor/ckeditor5-block-quote/issues/10). ([06de874](https://github.com/ckeditor/ckeditor5-block-quote/commit/06de874))
### Other changes
* Updated translations. ([5e23f86](https://github.com/ckeditor/ckeditor5-block-quote/commit/5e23f86))
## 0.1.0 (2017-04-05)
### Features
* Introduced the block quote feature. Closes [#1](https://github.com/ckeditor/ckeditor5-block-quote/issues/1). ([239015b](https://github.com/ckeditor/ckeditor5-block-quote/commit/239015b))

@ -1,4 +0,0 @@
Contributing
========================================
Information about contributing can be found at the following page: <https://github.com/ckeditor/ckeditor5/blob/master/CONTRIBUTING.md>.

@ -1,23 +0,0 @@
Software License Agreement
==========================
**CKEditor 5 Block Quote Feature** – https://github.com/ckeditor/ckeditor5-paragraph <br>
Copyright (c) 2003-2017, [CKSource](http://cksource.com) Frederico Knabben. All rights reserved.
Licensed under the terms of any of the following licenses at your choice:
* [GNU General Public License Version 2 or later (the "GPL")](http://www.gnu.org/licenses/gpl.html)
* [GNU Lesser General Public License Version 2.1 or later (the "LGPL")](http://www.gnu.org/licenses/lgpl.html)
* [Mozilla Public License Version 1.1 or later (the "MPL")](http://www.mozilla.org/MPL/MPL-1.1.html)
You are not required to, but if you want to explicitly declare the license you have chosen to be bound to when using, reproducing, modifying and distributing this software, just include a text file titled "legal.txt" in your version of this software, indicating your license choice. In any case, your choice will not restrict any recipient of your version of this software to use, reproduce, modify and distribute this software under any of the above licenses.
Sources of Intellectual Property Included in CKEditor
-----------------------------------------------------
Where not otherwise indicated, all CKEditor content is authored by CKSource engineers and consists of CKSource-owned intellectual property. In some specific instances, CKEditor will incorporate work done by developers outside of CKSource with their express permission.
Trademarks
----------
**CKEditor** is a trademark of [CKSource](http://cksource.com) Frederico Knabben. All other brand and product names are trademarks, registered trademarks or service marks of their respective holders.

@ -1,19 +0,0 @@
CKEditor 5 block quote feature
========================================
[![Join the chat at https://gitter.im/ckeditor/ckeditor5](https://badges.gitter.im/ckeditor/ckeditor5.svg)](https://gitter.im/ckeditor/ckeditor5?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
[![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-block-quote.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-block-quote)
[![Build Status](https://travis-ci.org/ckeditor/ckeditor5-block-quote.svg?branch=master)](https://travis-ci.org/ckeditor/ckeditor5-block-quote)
[![Test Coverage](https://codeclimate.com/github/ckeditor/ckeditor5-block-quote/badges/coverage.svg)](https://codeclimate.com/github/ckeditor/ckeditor5-block-quote/coverage)
[![Dependency Status](https://david-dm.org/ckeditor/ckeditor5-block-quote/status.svg)](https://david-dm.org/ckeditor/ckeditor5-block-quote)
[![devDependency Status](https://david-dm.org/ckeditor/ckeditor5-block-quote/dev-status.svg)](https://david-dm.org/ckeditor/ckeditor5-block-quote?type=dev)
This package implements block quote support for CKEditor 5.
## Documentation
See the [`@ckeditor/ckeditor5-block-quote` package](https://ckeditor5.github.io/docs/nightly/ckeditor5/latest/api/block-quote.html) page in [CKEditor 5 documentation](https://ckeditor5.github.io/docs/nightly/ckeditor5/latest/).
## License
Licensed under the GPL, LGPL and MPL licenses, at your choice. For full details about the license, please check the `LICENSE.md` file.

@ -1,41 +0,0 @@
{
"requires": true,
"lockfileVersion": 1,
"dependencies": {
"husky": {
"version": "0.14.3",
"resolved": "https://registry.npmjs.org/husky/-/husky-0.14.3.tgz",
"integrity": "sha512-e21wivqHpstpoiWA/Yi8eFti8E+sQDSS53cpJsPptPs295QTOQR0ZwnHo2TXy1XOpZFD9rPOd3NpmqTK6uMLJA==",
"requires": {
"is-ci": "1.0.10",
"normalize-path": "1.0.0",
"strip-indent": "2.0.0"
},
"dependencies": {
"ci-info": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-1.1.1.tgz",
"integrity": "sha512-vHDDF/bP9RYpTWtUhpJRhCFdvvp3iDWvEbuDbWgvjUrNGV1MXJrE0MPcwGtEled04m61iwdBLUIHZtDgzWS4ZQ=="
},
"is-ci": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/is-ci/-/is-ci-1.0.10.tgz",
"integrity": "sha1-9zkzayYyNlBhqdSCcM1WrjNpMY4=",
"requires": {
"ci-info": "1.1.1"
}
},
"normalize-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-1.0.0.tgz",
"integrity": "sha1-MtDkcvkf80VwHBWoMRAY07CpA3k="
},
"strip-indent": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-2.0.0.tgz",
"integrity": "sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g="
}
}
}
}
}

@ -1,58 +0,0 @@
{
"name": "@ckeditor/ckeditor5-block-quote",
"version": "1.0.0-alpha.1",
"description": "Block quote feature for CKEditor 5.",
"keywords": [
"ckeditor5",
"ckeditor5-feature"
],
"dependencies": {
"@ckeditor/ckeditor5-core": "^1.0.0-alpha.1",
"@ckeditor/ckeditor5-engine": "^1.0.0-alpha.1",
"@ckeditor/ckeditor5-ui": "^1.0.0-alpha.1",
"@ckeditor/ckeditor5-utils": "^1.0.0-alpha.1"
},
"devDependencies": {
"@ckeditor/ckeditor5-editor-classic": "^1.0.0-alpha.1",
"@ckeditor/ckeditor5-enter": "^1.0.0-alpha.1",
"@ckeditor/ckeditor5-essentials": "^1.0.0-alpha.1",
"@ckeditor/ckeditor5-image": "^1.0.0-alpha.1",
"@ckeditor/ckeditor5-list": "^1.0.0-alpha.1",
"@ckeditor/ckeditor5-paragraph": "^1.0.0-alpha.1",
"@ckeditor/ckeditor5-typing": "^1.0.0-alpha.1",
"eslint": "^4.8.0",
"eslint-config-ckeditor5": "^1.0.6",
"husky": "^0.14.3",
"lint-staged": "^4.2.3"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.0.0"
},
"author": "CKSource (http://cksource.com/)",
"license": "(GPL-2.0 OR LGPL-2.1 OR MPL-1.1)",
"homepage": "https://ckeditor5.github.io",
"bugs": "https://github.com/ckeditor/ckeditor5-block-quote/issues",
"repository": {
"type": "git",
"url": "https://github.com/ckeditor/ckeditor5-block-quote.git"
},
"files": [
"lang",
"src",
"theme"
],
"scripts": {
"lint": "eslint --quiet '**/*.js'",
"precommit": "lint-staged"
},
"lint-staged": {
"**/*.js": [
"eslint --quiet"
]
},
"eslintIgnore": [
"src/lib/**",
"packages/**"
]
}

@ -1,49 +0,0 @@
/**
* @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md.
*/
/**
* @module block-quote/blockquote
*/
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import buildModelConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildmodelconverter';
import buildViewConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildviewconverter';
export default class OpTableWidget extends Plugin {
static get pluginName() {
return 'OP-Table';
}
init() {
const editor = this.editor;
const data = editor.data;
const schema = editor.document.schema;
const editing = editor.editing;
schema.registerItem( 'table' );
schema.allow( { name: 'table', inside: '$root' } );
// thead
schema.allow( { name: 'thead', inside: 'table' } );
schema.allow( { name: 'tr', inside: 'thead' } );
schema.allow( { name: 'th', inside: 'tr' } );
// tbody
schema.allow( { name: 'tbody', inside: 'table' } );
schema.allow( { name: 'tr', inside: 'tbody' } );
schema.allow( { name: 'td', inside: 'tr' } );
// schema.allow( { name: '$block', inside: 'opTable' } );
buildModelConverter().for( data.modelToView, editing.modelToView )
.fromElement( 'opTable' )
.toElement('div')
// Build converter from view to model for data pipeline.
buildViewConverter().for( data.viewToModel )
.fromElement( 'div' )
.fromAttribute( 'class', 'op-ckeditor-widget--table')
.toElement('opTable');
}
}

@ -0,0 +1,13 @@
'use strict';
const path = require( 'path' );
const postcssImport = require( 'postcss-import' );
const postcssCssnext = require( 'postcss-cssnext' );
//const CKThemeImporter = require( './ck-theme-importer' );
module.exports = {
plugins: [
postcssImport(),
postcssCssnext()
]
};

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES6",
"allowJs": true,
"allowSyntheticDefaultImports": true,
"module": "ES6",
"moduleResolution": "node",
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": true,
"noEmitOnError": false,
// Increase strictness
"noImplicitAny": false,
"noImplicitThis": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"strictNullChecks": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"core-components/*": ["../app/components/*"],
"op-ckeditor/*": ["./*"]
}
},
"compileOnSave": false,
"exclude": [
"node_modules"
]
}

File diff suppressed because it is too large Load Diff

@ -34,6 +34,7 @@
"@angular/platform-browser": "^4.4.5",
"@angular/platform-browser-dynamic": "^4.4.5",
"@angular/upgrade": "^4.4.5",
"@openproject/commonmark-ckeditor-build": "git+https://github.com/opf/commonmark-ckeditor-build.git#2c776a29eaa01fa14bfb9053c019c5a9b78927ae",
"@types/angular": "^1.6.5",
"@types/angular-mocks": "^1.5.9",
"@types/assertion-error": "^1.0.30",
@ -76,6 +77,7 @@
"bundle-loader": "^0.5.4",
"clean-webpack-plugin": "^0.1.15",
"contra": "^1.9.4",
"copy-webpack-plugin": "^4.4.1",
"core-js": "^2.5.1",
"crossvent": "^1.5.4",
"css-loader": "^0.9.0",

@ -29,6 +29,7 @@
},
"compileOnSave": false,
"exclude": [
"node_modules"
"node_modules",
"ckeditor/*"
]
}

@ -0,0 +1,147 @@
// -- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See doc/COPYRIGHT.rdoc for more details.
// ++
var webpack = require('webpack');
var fs = require('fs');
var path = require('path');
var _ = require('lodash');
var autoprefixer = require('autoprefixer');
const CKEditorWebpackPlugin = require( '@ckeditor/ckeditor5-dev-webpack-plugin' );
var CleanWebpackPlugin = require('clean-webpack-plugin');
var ExtractTextPlugin = require('extract-text-webpack-plugin');
var mode = (process.env['RAILS_ENV'] || 'production').toLowerCase();
var uglify = (mode !== 'development');
var node_root = path.resolve(__dirname, 'node_modules');
var output_root = path.resolve(__dirname, '..', 'app', 'assets', 'javascripts');
var bundle_output = path.resolve(output_root, 'editor')
function getWebpackCKEConfig() {
config = {
entry: {
ckeditor: [path.resolve(__dirname, 'ckeditor', 'ckeditor.ts')]
},
module: {
rules: [
{
test: /\.tsx?$/,
include: [
path.resolve(__dirname, 'ckeditor'),
path.resolve(__dirname, 'app'),
],
use: [
{
loader: 'ts-loader',
options: {
logLevel: 'info',
configFile: path.resolve(__dirname, 'ckeditor', 'tsconfig.json')
}
}
]
},
{
// Or /ckeditor5-[^/]+\/theme\/icons\/[^/]+\.svg$/ if you want to limit this loader
// to CKEditor 5's icons only.
test: /\.svg$/,
use: [ 'raw-loader' ]
},
{
// Or /ckeditor5-[^/]+\/theme\/[^/]+\.scss$/ if you want to limit this loader
// to CKEditor 5's theme only.
test: /\.css$/,
use: [
'style-loader',
{
loader: 'postcss-loader',
options: {
config: { path: path.resolve(__dirname, 'ckeditor', 'postcss.config.js') }
}
}
]
},
{
// Or /ckeditor5-[^/]+\/theme\/[^/]+\.scss$/ if you want to limit this loader
// to CKEditor 5's theme only.
test: /\.scss$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
minimize: true
}
},
'sass-loader'
]
}
]
},
output: {
path: bundle_output,
filename: 'openproject-[name].js',
library: '[name]'
},
resolve: {
modules: ['node_modules'],
extensions: ['.ts', '.tsx', '.js'],
alias: _.merge({
'core-components': path.resolve(__dirname, 'app', 'components'),
'op-ckeditor': path.resolve(__dirname, 'ckeditor'),
})
},
plugins: [
// Editor i18n TODO
new CKEditorWebpackPlugin({
// See https://ckeditor5.github.io/docs/nightly/ckeditor5/latest/features/ui-language.html
languages: [ 'en' ]
}),
// Clean the output directory
new CleanWebpackPlugin(['editor'], {
root: output_root,
verbose: true
})
]
};
return config;
}
module.exports = getWebpackCKEConfig;

@ -187,7 +187,9 @@ function getWebpackMainConfig() {
},
resolve: {
modules: ['node_modules'],
modules: [
'node_modules',
],
extensions: ['.ts', '.tsx', '.js'],

@ -27,23 +27,20 @@
// ++
var webpack = require('webpack');
var fs = require('fs');
var path = require('path');
var _ = require('lodash');
var autoprefixer = require('autoprefixer');
var ExtractTextPlugin = require('extract-text-webpack-plugin');
var CleanWebpackPlugin = require('clean-webpack-plugin');
var CopyWebpackPlugin = require('copy-webpack-plugin');
var mode = (process.env['RAILS_ENV'] || 'production').toLowerCase();
var uglify = (mode !== 'development');
var node_root = path.resolve(__dirname, 'node_modules');
var output_root = path.resolve(__dirname, '..', 'app', 'assets', 'javascripts');
var bundle_output = path.resolve(output_root, 'bundles')
var ckeditor_build_dist_path = path.resolve(__dirname, 'node_modules', '@openproject', 'commonmark-ckeditor-build', 'dist', 'openproject-ckeditor.js');
function getWebpackVendorsConfig() {
config = {
var config = {
entry: {
vendors: [path.resolve(__dirname, 'app', 'vendors.js')]
},
@ -81,7 +78,16 @@ function getWebpackVendorsConfig() {
new CleanWebpackPlugin(['bundles'], {
root: output_root,
verbose: true
})
}),
// Copy linked ckeditor build dist
new CopyWebpackPlugin([
{
from: ckeditor_build_dist_path,
to: path.resolve(output_root, 'editor', 'openproject-ckeditor.js'),
toType: 'file'
}],
{ debug: 'info', copyUnmodified: true })
]
};

@ -30,7 +30,9 @@ var getWebpackMainConfig = require('./webpack-main-config');
var getWebpackTestConfig = require('./webpack-test-config');
module.exports = function(env) {
var configs = [getWebpackMainConfig()];
var configs = [
getWebpackMainConfig()
];
if (env && env.testconfig) {
console.log("Adding test config to build");

@ -29,6 +29,8 @@
require_dependency 'journal_formatter/base'
class OpenProject::JournalFormatter::Diff < JournalFormatter::Base
include OpenProject::StaticRouting::UrlHelpers
def render(key, values, options = {})
merge_options = { only_path: true,
no_html: false }.merge(options)

@ -34,17 +34,13 @@ module OpenProject::TextFormatting
# Convert Markdown to HTML using CommonMarker
def call
$stderr.puts "MARKDOWN"
html = ''
$stderr.puts(Benchmark.measure do
options = [:GITHUB_PRE_LANG]
options << :HARDBREAKS if context[:gfm] != false
extensions = context.fetch :commonmarker_extensions,
%i[table strikethrough tagfilter autolink]
options = [:GITHUB_PRE_LANG]
options << :HARDBREAKS if context[:gfm] != false
extensions = context.fetch :commonmarker_extensions,
%i[table strikethrough tagfilter autolink]
html = CommonMarker.render_html(text, options, extensions)
html.rstrip!
end)
html = CommonMarker.render_html(text, options, extensions)
html.rstrip!
html
end

@ -39,14 +39,11 @@ module OpenProject::TextFormatting
end
def call
$stderr.puts "REGEX"
$stderr.puts(Benchmark.measure do
doc.search('.//text()').each do |node|
self.class.matchers.each do |matcher|
matcher.call(node, doc: doc, context: context)
end
doc.search('.//text()').each do |node|
self.class.matchers.each do |matcher|
matcher.call(node, doc: doc, context: context)
end
end)
end
doc
end

@ -31,14 +31,6 @@
module OpenProject::TextFormatting
module Filters
class SanitizationFilter < HTML::Pipeline::SanitizationFilter
def call
$stderr.puts "SANITIZE"
$stderr.puts(Benchmark.measure do
super
end)
doc
end
end
end
end

@ -1,5 +1,4 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2017 the OpenProject Foundation (OPF)
@ -28,33 +27,23 @@
# See doc/COPYRIGHT.rdoc for more details.
#++
module OpenProject::TextFormatting
class Pipeline
attr_reader :formatter,
:context,
:pipeline
def initialize(formatter, context:)
@formatter = formatter
@context = context
module OpenProject::TextFormatting::Formatters
class Base
attr_reader :options, :project
@pipeline = HTML::Pipeline.new(located_filters, context)
def initialize(options)
@options = options
@project = options[:project]
end
def to_html(text, call_context = {})
pipeline.to_html(text, call_context).html_safe
def to_html(text)
raise NotImplementedError
end
def to_document(text, call_context = {})
pipeline.to_document text, call_context
end
protected
def filters
[
formatter,
:sanitization,
:pattern_matcher
]
[]
end
protected
@ -70,4 +59,3 @@ module OpenProject::TextFormatting
end
end
end

@ -29,8 +29,42 @@
module OpenProject::TextFormatting::Formatters
module Markdown
class Formatter
# TODO Used only for consistency with the formatters registry.
class Formatter < OpenProject::TextFormatting::Formatters::Base
attr_reader :context,
:pipeline
def initialize(context)
@context = context
@pipeline = HTML::Pipeline.new(located_filters, context)
end
def to_html(text)
pipeline.to_html(text, context).html_safe
end
def to_document(text)
pipeline.to_document text, context
end
def filters
[
:markdown,
:sanitization,
:pattern_matcher
]
end
protected
def located_filters
filters.map do |f|
if [Symbol, String].include? f.class
OpenProject::TextFormatting::Filters.const_get("#{f}_filter".classify)
else
f
end
end
end
end
end
end

@ -31,18 +31,12 @@ module OpenProject::TextFormatting::Formatters
module Markdown
module Helper
def wikitoolbar_for(field_id)
javascript_tag(<<-EOF)
// Toolbar for markdown. Here be dragons
EOF
# Kept only for compatibility
end
def initial_page_content(_page)
"h1. #{@page.title}"
end
def heads_for_wiki_formatter
end
end
end
end

@ -0,0 +1,189 @@
# Textile to Markdown converter
# Based on redmine_convert_textile_to_markown
# https://github.com/Ecodev/redmine_convert_textile_to_markown
#
# Original license:
# Copyright (c) 2016
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
require 'open3'
module OpenProject::TextFormatting::Formatters
module Markdown
class TextileConverter
attr_reader :src, :dst
def initialize
end
def run!
puts 'Starting conversion of Textile fields to CommonMark+GFM.'
ActiveRecord::Base.transaction do
converters.each do |handler|
handler.call!
end
end
puts "\n-- Completed --"
ensure
cleanup
end
private
def converters
[
method(:convert_settings)
method(:convert_models)
]
end
def cleanup
src.close
dst.close
end
def convert_settings
print 'Converting settings '
Setting.welcome_text = convert_textile_to_markdown(Setting.welcome_text)
print '.'
Setting.registration_footer = Setting.registration_footer.dup.tap do |footer|
footer.transform_values { |val| convert_textile_to_markdown(val) }
print '.'
end
puts 'done'
end
def convert_models
models_to_convert.each do |the_class, attributes|
print "#{the_class.name} "
# Iterate in batches to avoid plucking too much
the_class.in_batches(of: 200) do |relation|
relation.pluck(:id, *attributes).each do |values|
# Zip converted texts into
# { attr_a: textile, ... }
converted = values.drop(1).map(&method(:convert_textile_to_markdown))
update_hash = Hash[attributes.zip(converted)]
the_class.where(id: values.first).update_all(update_hash)
print '.'
end
end
puts 'done'
end
end
def convert_textile_to_markdown(textile)
return '' unless textile.present?
# Redmine support @ inside inline code marked with @ (such as "@git@github.com@"), but not pandoc.
# So we inject a placeholder that will be replaced later on with a real backtick.
tag_code = 'pandoc-unescaped-single-backtick'
textile.gsub!(/@([\S]+@[\S]+)@/, tag_code + '\\1' + tag_code)
# Drop table colspan/rowspan notation ("|\2." or "|/2.") because pandoc does not support it
# See https://github.com/jgm/pandoc/issues/22
textile.gsub!(/\|[\/\\]\d\. /, '| ')
# Drop table alignement notation ("|>." or "|<." or "|=.") because pandoc does not support it
# See https://github.com/jgm/pandoc/issues/22
textile.gsub!(/\|[<>=]\. /, '| ')
# Move the class from <code> to <pre> so pandoc can generate a code block with correct language
textile.gsub!(/(<pre)(><code)( class="[^"]*")(>)/, '\\1\\3\\2\\4')
# Remove the <code> directly inside <pre>, because pandoc would incorrectly preserve it
textile.gsub!(/(<pre[^>]*>)<code>/, '\\1')
textile.gsub!(/<\/code>(<\/pre>)/, '\\1')
# Inject a class in all <pre> that do not have a blank line before them
# This is to force pandoc to use fenced code block (```) otherwise it would
# use indented code block and would very likely need to insert an empty HTML
# comment "<!-- -->" (see http://pandoc.org/README.html#ending-a-list)
# which are unfortunately not supported by Redmine (see http://www.redmine.org/issues/20497)
tag_fenced_code_block = 'force-pandoc-to-ouput-fenced-code-block'
textile.gsub!(/([^\n]<pre)(>)/, "\\1 class=\"#{tag_fenced_code_block}\"\\2")
# Force <pre> to have a blank line before them
# Without this fix, a list of items containing <pre> would not be interpreted as a list at all.
textile.gsub!(/([^\n])(<pre)/, "\\1\n\n\\2")
# Some malformed textile content make pandoc run extremely slow,
# so we convert it to proper textile before hitting pandoc
# see https://github.com/jgm/pandoc/issues/3020
textile.gsub!(/- # (\d+)/, "* \\1")
command = %w(pandoc --wrap=preserve -f textile -t gfm)
markdown, stderr_str, status = Open3.capture3(*command, stdin_data: textile)
raise 'Pandoc failed: #{stderr.read}' unless status.success?
# Remove the \ pandoc puts before * and > at begining of lines
markdown.gsub!(/^((\\[*>])+)/) { $1.gsub("\\", "") }
# Add a blank line before lists
markdown.gsub!(/^([^*].*)\n\*/, "\\1\n\n*")
# Remove the injected tag
markdown.gsub!(' ' + tag_fenced_code_block, '')
# Replace placeholder with real backtick
markdown.gsub!(tag_code, '`')
# Un-escape Redmine link syntax to wiki pages
markdown.gsub!('\[\[', '[[')
markdown.gsub!('\]\]', ']]')
# Un-escape Redmine quotation mark "> " that pandoc is not aware of
markdown.gsub!(/(^|\n)&gt; /, "\n> ")
return markdown
end
def models_to_convert
{
Announcement => [:text],
AttributeHelpText => [:help_text],
Comment => [:text],
WikiContent => [:text],
WorkPackage => [:description],
Message => [:content],
News => [:description],
Document => [:description],
Project => [:description],
Journal => [:notes],
AttachmentJournal => [:text],
MessageJournal => [:content],
WikiContentJournal => [:text],
WorkPackageJournal => [:description],
## TODO
# CF Long text values
# Documents
# Meetings
}
end
end
end
end

@ -29,18 +29,25 @@
module OpenProject::TextFormatting::Formatters
module Plain
class Formatter
include ERB::Util
include ActionView::Helpers::TagHelper
include ActionView::Helpers::TextHelper
include ActionView::Helpers::UrlHelper
class Formatter < OpenProject::TextFormatting::Formatters::Base
attr_reader :context,
:pipeline
def initialize(text)
@text = text
def initialize(context)
@context = context
@pipeline = HTML::Pipeline.new(located_filters, context)
end
def to_html(*_args)
simple_format(auto_link(CGI::escapeHTML(@text)))
def to_html(text)
pipeline.to_html(text, context).html_safe
end
def to_document(text)
pipeline.to_document text, context
end
def filters
%i(plain pattern_matcher)
end
end
end

@ -27,117 +27,485 @@
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'redcloth3'
module OpenProject::TextFormatting::Formatters
module Textile
class Formatter < RedCloth3
class Formatter < OpenProject::TextFormatting::Formatters::Base
attr_reader
include Redmine::WikiFormatting::Macros::Definitions
include ActionView::Helpers::SanitizeHelper
include Redmine::I18n
# used for the work package quick links
include WorkPackagesHelper
include ApplicationHelper
# Used for escaping helper 'h()'
include ERB::Util
# Rails helper
include ActionView::Context
include ActionView::Helpers::TagHelper
include ActionView::Helpers::UrlHelper
include ActionView::Helpers::TextHelper
# For route path helpers
include OpenProject::ObjectLinking
include OpenProject::StaticRouting::UrlHelpers
# Truncation
include OpenProject::TextFormatting::Truncation
def controller; end
# auto_link rule after textile rules so that it doesn't break !image_url! tags
RULES = [:textile, :block_markdown_rule, :inline_auto_link, :inline_auto_mailto]
def to_html(text)
edit = !!options[:edit]
# don't return html in edit mode when textile or text formatting is enabled
return text if edit
def initialize(*args)
super
object = options[:object]
project = options[:project]
only_path = options.delete(:only_path) != false
# offer 'plain' as readable version for 'no formatting' to callers
format = options.delete(:format) { :textile }
text = RedclothWrapper.new(text).to_html
# TODO: transform modifications into WikiFormatting Helper, or at least ask the helper if he wants his stuff to be modified
@parsed_headings = []
text = parse_non_pre_blocks(text) { |text|
[:execute_macros, :parse_inline_attachments, :parse_wiki_links, :parse_redmine_links, :parse_headings, :parse_relative_urls].each do |method_name|
send method_name, text, project, object, options[:attribute], only_path, options
end
}
self.hard_breaks = true
self.no_span_caps = true
self.filter_styles = true
if @parsed_headings.any?
replace_toc(text, @parsed_headings, options)
end
escape_non_macros(text)
text.html_safe
end
def to_html(*_rules)
@toc = []
super(*RULES).to_s
##
# Escape double curly braces after macro expansion.
# This will avoid arbitrary angular expressions to be evaluated in
# formatted text marked html_safe.
def escape_non_macros(text)
text.gsub!(/\{\{(?! \$root\.DOUBLE_LEFT_CURLY_BRACE)/, '{{ $root.DOUBLE_LEFT_CURLY_BRACE }}')
end
def parse_non_pre_blocks(text)
s = StringScanner.new(text)
tags = []
parsed = ''
while !s.eos?
s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
text = s[1]
full_tag = s[2]
closing = s[3]
tag = s[4]
if tags.empty?
yield text
end
parsed << text
if tag
if closing
if tags.last == tag.downcase
tags.pop
end
else
tags << tag.downcase
end
parsed << full_tag
end
end
# Close any non closing tags
while tag = tags.pop
parsed << "</#{tag}>"
end
parsed
end
private
# Patch for RedCloth. Fixed in RedCloth r128 but _why hasn't released it yet.
# <a href="http://code.whytheluckystiff.net/redcloth/changeset/128">http://code.whytheluckystiff.net/redcloth/changeset/128</a>
def hard_break(text)
text.gsub!(/(.)\n(?!\n|\Z| *([#*=]+(\s|$)|[{|]))/, '\\1<br />') if hard_breaks
MACROS_RE = /
(!)? # escaping
(
\{\{ # opening tag
([\w]+) # macro name
(\(([^\}]*)\))? # optional arguments
\}\} # closing tag
)
/x unless const_defined?(:MACROS_RE)
# Macros substitution
def execute_macros(text, project, obj, _attr, _only_path, options)
return if !!options[:edit]
text.gsub!(MACROS_RE) do
esc = $1
all = $2
macro = $3
args = ($5 || '').split(',').each(&:strip!)
if esc.nil?
begin
exec_macro(macro, obj, args, view: self, project: project)
rescue => e
"<span class=\"flash error macro-unavailable permanent\">\
#{::I18n.t(:macro_execution_error, macro_name: macro)} (#{e})\
</span>".squish
rescue NotImplementedError
"<span class=\"flash error macro-unavailable permanent\">\
#{::I18n.t(:macro_unavailable, macro_name: macro)}\
</span>".squish
end || all
else
all
end
end
end
# Patch to add code highlighting support to RedCloth
def smooth_offtags(text)
unless @pre_list.empty?
## replace <pre> content
text.gsub!(/<redpre#(\d+)>/) do
content = @pre_list[$1.to_i]
if content.match(/<code\s+class="(\w+)">\s?(.+)/m)
content = "<code class=\"#{$1} CodeRay\">" +
Redmine::SyntaxHighlighting.highlight_by_language($2, $1)
RELATIVE_LINK_RE = %r{
<a
(?:
(\shref=
(?: # the href and link
(?:'(\/[^>]+?)')|
(?:"(\/[^>]+?)")
)
)|
[^>]
)*
>
[^<]*?<\/a> # content and closing link tag.
}x unless const_defined?(:RELATIVE_LINK_RE)
def parse_relative_urls(text, _project, _obj, _attr, only_path, _options)
return if only_path
text.gsub!(RELATIVE_LINK_RE) do |m|
href = $1
relative_url = $2 || $3
next m unless href.present?
request = options[:request]
if request.present?
# we have a request!
protocol = request.protocol
host_with_port = request.host_with_port
elsif @controller
# use the same methods as url_for in the Mailer
url_opts = @controller.class.default_url_options
next m unless url_opts && url_opts[:protocol] && url_opts[:host]
protocol = "#{url_opts[:protocol]}://"
host_with_port = url_opts[:host]
else
next m
end
m.sub href, " href=\"#{protocol}#{host_with_port}#{relative_url}\""
end
end
def parse_inline_attachments(text, _project, obj, _attr, only_path, options)
# when using an image link, try to use an attachment, if possible
if options[:attachments] || (obj && obj.respond_to?(:attachments))
attachments = nil
text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
filename = $1.downcase
ext = $2
alt = $3
alttext = $4
attachments ||= (options[:attachments] || obj.attachments).sort_by(&:created_on).reverse
# search for the picture in attachments
if found = attachments.detect { |att| att.filename.downcase == filename }
image_url = url_for only_path: only_path, controller: '/attachments', action: 'download', id: found
desc = found.description.to_s.gsub('"', '')
if !desc.blank? && alttext.blank?
alt = " title=\"#{desc}\" alt=\"#{desc}\""
end
"src=\"#{image_url}\"#{alt}"
else
m
end
content
end
end
end
# Wiki links
#
# Examples:
# [[mypage]]
# [[mypage|mytext]]
# wiki links can refer other project wikis, using project name or identifier:
# [[project:]] -> wiki starting page
# [[project:|mytext]]
# [[project:mypage]]
# [[project:mypage|mytext]]
def parse_wiki_links(text, project, _obj, _attr, only_path, options)
text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |_m|
link_project = project
esc = $1
all = $2
page = $3
title = $5
if esc.nil?
if page =~ /\A([^\:]+)\:(.*)\z/
link_project = Project.find_by(identifier: $1) || Project.find_by(name: $1)
page = $2
title ||= $1 if page.blank?
end
def auto_link_regexp
@auto_link_regexp ||= begin
%r{
( # leading text
<\w+.*?>| # leading HTML tag, or
[^=<>!:'"/]| # leading punctuation, or
\{\{\w+\(| # inside a macro?
^ # beginning of line
)
(
(?:https?://)| # protocol spec, or
(?:s?ftps?://)|
(?:www\.) # www.*
)
(
(\S+?) # url
(\/)? # slash
)
((?:&gt;)?|[^\w\=\/;\(\)]*?) # post
(?=<|\s|$)
}x
if link_project && link_project.wiki
# extract anchor
anchor = nil
if page =~ /\A(.+?)\#(.+)\z/
page = $1
anchor = $2
end
# Unescape the escaped entities from textile
page = CGI.unescapeHTML(page)
# check if page exists
wiki_page = link_project.wiki.find_page(page)
wiki_title = wiki_page.nil? ? page : wiki_page.title
url = case options[:wiki_links]
when :local;
"#{title}.html"
when :anchor;
"##{title}" # used for single-file wiki export
else
wiki_page_id = wiki_page.nil? ? page.to_url : wiki_page.slug
url_for(only_path: only_path, controller: '/wiki', action: 'show', project_id: link_project, id: wiki_page_id, anchor: anchor)
end
link_to(h(title || wiki_title), url, class: ('wiki-page' + (wiki_page ? '' : ' new')))
else
# project or wiki doesn't exist
all
end
else
all
end
end
end
# Turns all urls into clickable links (code from Rails).
def inline_auto_link(text)
text.gsub!(auto_link_regexp) do
all = $&
# Redmine links
#
# Examples:
# Issues:
# #52 -> Link to issue #52
# Changesets:
# r52 -> Link to revision 52
# commit:a85130f -> Link to scmid starting with a85130f
# Documents:
# document#17 -> Link to document with id 17
# document:Greetings -> Link to the document with title "Greetings"
# document:"Some document" -> Link to the document with title "Some document"
# Versions:
# version#3 -> Link to version with id 3
# version:1.0.0 -> Link to version named "1.0.0"
# version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
# Attachments:
# attachment:file.zip -> Link to the attachment of the current object named file.zip
# Source files:
# source:some/file -> Link to the file located at /some/file in the project's repository
# source:some/file@52 -> Link to the file's revision 52
# source:some/file#L120 -> Link to line 120 of the file
# source:some/file@52#L120 -> Link to line 120 of the file's revision 52
# export:some/file -> Force the download of the file
# Forum messages:
# message#1218 -> Link to message with id 1218
#
# Links can refer other objects from other projects, using project identifier:
# identifier:r52
# identifier:document:"Some document"
# identifier:version:1.0.0
# identifier:source:some/file
def parse_redmine_links(text, project, obj, attr, only_path, options)
text.gsub!(%r{([\s\(,\-\[\>]|^)(!)?(([a-z0-9\-_]+):)?(attachment|version|commit|source|export|message|project|user)?((#+|r)(\d+)|(:)([^"\s<>][^\s<>]*?|"[^"]+?"))(?=(?=[[:punct:]]\W)|,|\s|\]|<|$)}) do |_m|
leading = $1
proto = $2
url = $3
post = $6
if url.nil? || leading =~ /<a\s/i || leading =~ /![<>=]?/ || leading =~ /\{\{\w+\(/
# don't replace URLs that are already linked
# and URLs prefixed with ! !> !< != (textile images)
all
else
# Idea below : an URL with unbalanced parethesis and
# ending by ')' is put into external parenthesis
if url[-1] == ?) and ((url.count('(') - url.count(')')) < 0)
url = url[0..-2] # discard closing parenth from url
post = ')' + post # add closing parenth to post
esc = $2
project_prefix = $3
project_identifier = $4
prefix = $5
sep = $7 || $9
identifier = $8 || $10
link = nil
if project_identifier
project = Project.visible.find_by(identifier: project_identifier)
end
if esc.nil?
if prefix.nil? && sep == 'r'
# project.changesets.visible raises an SQL error because of a double join on repositories
if project && project.repository && (changeset = Changeset.visible.find_by(repository_id: project.repository.id, revision: identifier))
link = link_to(h("#{project_prefix}r#{identifier}"), { only_path: only_path, controller: '/repositories', action: 'revision', project_id: project, rev: changeset.revision },
class: 'changeset',
title: truncate_single_line(changeset.comments, length: 100))
end
elsif sep == '#'
oid = identifier.to_i
case prefix
when nil
if work_package = WorkPackage.visible
.includes(:status)
.references(:statuses)
.find_by(id: oid)
link = link_to("##{oid}",
work_package_path_or_url(id: oid, only_path: only_path),
class: work_package_css_classes(work_package),
title: "#{truncate(work_package.subject, length: 100)} (#{work_package.status.try(:name)})")
end
when 'version'
if version = Version.visible.find_by(id: oid)
link = link_to h(version.name), { only_path: only_path, controller: '/versions', action: 'show', id: version },
class: 'version'
end
when 'message'
if message = Message.visible.includes(:parent).find_by(id: oid)
link = link_to_message(message, { only_path: only_path }, class: 'message')
end
when 'project'
if p = Project.visible.find_by(id: oid)
link = link_to_project(p, { only_path: only_path }, class: 'project')
end
when 'user'
if user = User.in_visible_project.find_by(id: oid)
link = link_to_user(user, class: 'user-mention')
end
end
elsif sep == '##'
oid = identifier.to_i
if work_package = WorkPackage.visible
.includes(:status)
.references(:statuses)
.find_by(id: oid)
link = work_package_quick_info(work_package, only_path: only_path)
end
elsif sep == '###'
oid = identifier.to_i
work_package = WorkPackage.visible
.includes(:status)
.references(:statuses)
.find_by(id: oid)
if work_package && obj && !(attr == :description && obj.id == work_package.id)
link = work_package_quick_info_with_description(work_package, only_path: only_path)
end
elsif sep == ':'
# removes the double quotes if any
name = identifier.gsub(%r{\A"(.*)"\z}, '\\1')
case prefix
when 'version'
if project && version = project.versions.visible.find_by(name: name)
link = link_to h(version.name), { only_path: only_path, controller: '/versions', action: 'show', id: version },
class: 'version'
end
when 'commit'
if project && project.repository && (changeset = Changeset.visible.where(['repository_id = ? AND scmid LIKE ?', project.repository.id, "#{name}%"]).first)
link = link_to h("#{project_prefix}#{name}"), { only_path: only_path, controller: '/repositories', action: 'revision', project_id: project, rev: changeset.identifier },
class: 'changeset',
title: truncate_single_line(changeset.comments, length: 100)
end
when 'source', 'export'
if project && project.repository && User.current.allowed_to?(:browse_repository, project)
name =~ %r{\A[/\\]*(.*?)(@([0-9a-f]+))?(#(L\d+))?\z}
path = $1
rev = $3
anchor = $5
link = link_to h("#{project_prefix}#{prefix}:#{name}"), { controller: '/repositories', action: 'entry', project_id: project,
path: path.to_s,
rev: rev,
anchor: anchor,
format: (prefix == 'export' ? 'raw' : nil) },
class: (prefix == 'export' ? 'source download' : 'source')
end
when 'attachment'
attachments = options[:attachments] || (obj && obj.respond_to?(:attachments) ? obj.attachments : nil)
if attachments && attachment = attachments.detect { |a| a.filename == name }
link = link_to h(attachment.filename), { only_path: only_path, controller: '/attachments', action: 'download', id: attachment },
class: 'attachment'
end
when 'project'
p = Project
.visible
.where(['projects.identifier = :s OR LOWER(projects.name) = :s',
{ s: name.downcase }])
.first
if p
link = link_to_project(p, { only_path: only_path }, class: 'project')
end
when 'user'
if user = User.in_visible_project.find_by(login: name)
link = link_to_user(user, class: 'user-mention')
end
end
end
tag = content_tag('a',
proto + url,
href: "#{proto == 'www.' ? 'http://www.' : proto}#{url}",
class: 'external icon-context icon-copy')
%(#{leading}#{tag}#{post})
end
leading + (link || "#{project_prefix}#{prefix}#{sep}#{identifier}")
end
end
HEADING_RE = /<h(1|2|3|4)( [^>]+)?>(.+?)<\/h(1|2|3|4)>/i unless const_defined?(:HEADING_RE)
# Headings and TOC
# Adds ids and links to headings unless options[:headings] is set to false
def parse_headings(text, _project, _obj, _attr, _only_path, options)
return if options[:headings] == false
text.gsub!(HEADING_RE) do
level = $1.to_i
attrs = $2
content = $3
item = strip_tags(content).strip
tocitem = strip_tags(content.gsub(/<br \/>/, ' '))
anchor = item.gsub(%r{[^\w\s\-]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
@parsed_headings << [level, anchor, tocitem]
url = full_url(anchor, options[:request])
"<a name=\"#{anchor}\"></a>\n<h#{level} #{attrs}>#{content}<a href=\"#{url}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
end
end
# Turns all email addresses into clickable links (code from Rails).
def inline_auto_mailto(text)
text.gsub!(/((?<!user:")\b[\w\.!#\$%\-+.]+@[A-Za-z0-9\-]+(\.[A-Za-z0-9\-]+)+)/) do
mail = $1
if text.match(/<a\b[^>]*>(.*)(#{Regexp.escape(mail)})(.*)<\/a>/)
mail
TOC_RE = /<p>\{\{([<>]?)toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
# Renders the TOC with given headings
def replace_toc(text, headings, options)
text.gsub!(TOC_RE) do
if headings.empty?
''
else
content_tag('a', mail, href: "mailto:#{mail}", class: 'email')
div_class = 'toc'
div_class << ' right' if $1 == '>'
div_class << ' left' if $1 == '<'
out = "<fieldset class='form--fieldset -collapsible'>"
out << "<legend class='form--fieldset-legend' title='" +
l(:description_toc_toggle) +
"' onclick='toggleFieldset(this);'>
<a href='javascript:'>
#{l(:label_table_of_contents)}
</a>
</legend><div>"
out << "<ul class=\"#{div_class}\"><li>"
root = headings.map(&:first).min
current = root
started = false
headings.each do |level, anchor, item|
if level > current
out << '<ul><li>' * (level - current)
elsif level < current
out << "</li></ul>\n" * (current - level) + '</li><li>'
elsif started
out << '</li><li>'
end
url = full_url(anchor, options[:request])
out << "<a href=\"#{url}\">#{item}</a>"
current = level
started = true
end
out << '</li></ul>' * (current - root)
out << '</li></ul>'
out << '</div></fieldset>'
end
end
end
#
# displays the current url plus an optional anchor
#
def full_url(anchor_name = '', current_request = nil)
return "##{anchor_name}" if current_request.nil?
current = current_request.original_fullpath
return current if anchor_name.blank?
"#{current}##{anchor_name}"
end
end
end
end

@ -1,514 +0,0 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2017 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.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
module OpenProject::TextFormatting::Formatters
module Textile
class LegacyTextFormatting
include Redmine::WikiFormatting::Macros::Definitions
include ActionView::Helpers::SanitizeHelper
include Redmine::I18n
# used for the work package quick links
include WorkPackagesHelper
# Used for escaping helper 'h()'
include ERB::Util
# Rails helper
include ActionView::Helpers::TagHelper
include ActionView::Helpers::UrlHelper
include ActionView::Helpers::TextHelper
# For route path helpers
include OpenProject::ObjectLinking
include OpenProject::StaticRouting::UrlHelpers
# Truncation
include OpenProject::TextFormatting::Truncation
def initialize(project)
@project = project
end
def controller; end
# Formats text according to system settings.
# 2 ways to call this method:
# * with a String: format_text(text, options)
# * with an object and one of its attribute: format_text(issue, :description, options)
def format_text(text, options)
edit = !!options[:edit]
# don't return html in edit mode when textile or text formatting is enabled
return text if edit
object = options[:object]
project = options[:project]
only_path = options.delete(:only_path) != false
# offer 'plain' as readable version for 'no formatting' to callers
format = options.delete(:format) { :textile }
text = OpenProject::TextFormatting::Formatters.formatter_for(format).new(text).to_html
# TODO: transform modifications into WikiFormatting Helper, or at least ask the helper if he wants his stuff to be modified
@parsed_headings = []
text = parse_non_pre_blocks(text) { |text|
[:execute_macros, :parse_inline_attachments, :parse_wiki_links, :parse_redmine_links, :parse_headings, :parse_relative_urls].each do |method_name|
send method_name, text, project, object, options[:attribute], only_path, options
end
}
if @parsed_headings.any?
replace_toc(text, @parsed_headings, options)
end
escape_non_macros(text)
text.html_safe
end
##
# Escape double curly braces after macro expansion.
# This will avoid arbitrary angular expressions to be evaluated in
# formatted text marked html_safe.
def escape_non_macros(text)
text.gsub!(/\{\{(?! \$root\.DOUBLE_LEFT_CURLY_BRACE)/, '{{ $root.DOUBLE_LEFT_CURLY_BRACE }}')
end
def parse_non_pre_blocks(text)
s = StringScanner.new(text)
tags = []
parsed = ''
while !s.eos?
s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
text = s[1]
full_tag = s[2]
closing = s[3]
tag = s[4]
if tags.empty?
yield text
end
parsed << text
if tag
if closing
if tags.last == tag.downcase
tags.pop
end
else
tags << tag.downcase
end
parsed << full_tag
end
end
# Close any non closing tags
while tag = tags.pop
parsed << "</#{tag}>"
end
parsed
end
MACROS_RE = /
(!)? # escaping
(
\{\{ # opening tag
([\w]+) # macro name
(\(([^\}]*)\))? # optional arguments
\}\} # closing tag
)
/x unless const_defined?(:MACROS_RE)
# Macros substitution
def execute_macros(text, project, obj, _attr, _only_path, options)
return if !!options[:edit]
text.gsub!(MACROS_RE) do
esc = $1
all = $2
macro = $3
args = ($5 || '').split(',').each(&:strip!)
if esc.nil?
begin
exec_macro(macro, obj, args, view: self, project: project)
rescue => e
"<span class=\"flash error macro-unavailable permanent\">\
#{::I18n.t(:macro_execution_error, macro_name: macro)} (#{e})\
</span>".squish
rescue NotImplementedError
"<span class=\"flash error macro-unavailable permanent\">\
#{::I18n.t(:macro_unavailable, macro_name: macro)}\
</span>".squish
end || all
else
all
end
end
end
RELATIVE_LINK_RE = %r{
<a
(?:
(\shref=
(?: # the href and link
(?:'(\/[^>]+?)')|
(?:"(\/[^>]+?)")
)
)|
[^>]
)*
>
[^<]*?<\/a> # content and closing link tag.
}x unless const_defined?(:RELATIVE_LINK_RE)
def parse_relative_urls(text, _project, _obj, _attr, only_path, _options)
return if only_path
text.gsub!(RELATIVE_LINK_RE) do |m|
href = $1
relative_url = $2 || $3
next m unless href.present?
if defined?(request) && request.present?
# we have a request!
protocol = request.protocol
host_with_port = request.host_with_port
elsif @controller
# use the same methods as url_for in the Mailer
url_opts = @controller.class.default_url_options
next m unless url_opts && url_opts[:protocol] && url_opts[:host]
protocol = "#{url_opts[:protocol]}://"
host_with_port = url_opts[:host]
else
next m
end
m.sub href, " href=\"#{protocol}#{host_with_port}#{relative_url}\""
end
end
def parse_inline_attachments(text, _project, obj, _attr, only_path, options)
# when using an image link, try to use an attachment, if possible
if options[:attachments] || (obj && obj.respond_to?(:attachments))
attachments = nil
text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
filename = $1.downcase
ext = $2
alt = $3
alttext = $4
attachments ||= (options[:attachments] || obj.attachments).sort_by(&:created_on).reverse
# search for the picture in attachments
if found = attachments.detect { |att| att.filename.downcase == filename }
image_url = url_for only_path: only_path, controller: '/attachments', action: 'download', id: found
desc = found.description.to_s.gsub('"', '')
if !desc.blank? && alttext.blank?
alt = " title=\"#{desc}\" alt=\"#{desc}\""
end
"src=\"#{image_url}\"#{alt}"
else
m
end
end
end
end
# Wiki links
#
# Examples:
# [[mypage]]
# [[mypage|mytext]]
# wiki links can refer other project wikis, using project name or identifier:
# [[project:]] -> wiki starting page
# [[project:|mytext]]
# [[project:mypage]]
# [[project:mypage|mytext]]
def parse_wiki_links(text, project, _obj, _attr, only_path, options)
text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |_m|
link_project = project
esc = $1
all = $2
page = $3
title = $5
if esc.nil?
if page =~ /\A([^\:]+)\:(.*)\z/
link_project = Project.find_by(identifier: $1) || Project.find_by(name: $1)
page = $2
title ||= $1 if page.blank?
end
if link_project && link_project.wiki
# extract anchor
anchor = nil
if page =~ /\A(.+?)\#(.+)\z/
page = $1
anchor = $2
end
# Unescape the escaped entities from textile
page = CGI.unescapeHTML(page)
# check if page exists
wiki_page = link_project.wiki.find_page(page)
wiki_title = wiki_page.nil? ? page : wiki_page.title
url = case options[:wiki_links]
when :local;
"#{title}.html"
when :anchor;
"##{title}" # used for single-file wiki export
else
wiki_page_id = wiki_page.nil? ? page.to_url : wiki_page.slug
url_for(only_path: only_path, controller: '/wiki', action: 'show', project_id: link_project, id: wiki_page_id, anchor: anchor)
end
link_to(h(title || wiki_title), url, class: ('wiki-page' + (wiki_page ? '' : ' new')))
else
# project or wiki doesn't exist
all
end
else
all
end
end
end
# Redmine links
#
# Examples:
# Issues:
# #52 -> Link to issue #52
# Changesets:
# r52 -> Link to revision 52
# commit:a85130f -> Link to scmid starting with a85130f
# Documents:
# document#17 -> Link to document with id 17
# document:Greetings -> Link to the document with title "Greetings"
# document:"Some document" -> Link to the document with title "Some document"
# Versions:
# version#3 -> Link to version with id 3
# version:1.0.0 -> Link to version named "1.0.0"
# version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
# Attachments:
# attachment:file.zip -> Link to the attachment of the current object named file.zip
# Source files:
# source:some/file -> Link to the file located at /some/file in the project's repository
# source:some/file@52 -> Link to the file's revision 52
# source:some/file#L120 -> Link to line 120 of the file
# source:some/file@52#L120 -> Link to line 120 of the file's revision 52
# export:some/file -> Force the download of the file
# Forum messages:
# message#1218 -> Link to message with id 1218
#
# Links can refer other objects from other projects, using project identifier:
# identifier:r52
# identifier:document:"Some document"
# identifier:version:1.0.0
# identifier:source:some/file
def parse_redmine_links(text, project, obj, attr, only_path, options)
text.gsub!(%r{([\s\(,\-\[\>]|^)(!)?(([a-z0-9\-_]+):)?(attachment|version|commit|source|export|message|project|user)?((#+|r)(\d+)|(:)([^"\s<>][^\s<>]*?|"[^"]+?"))(?=(?=[[:punct:]]\W)|,|\s|\]|<|$)}) do |_m|
leading = $1
esc = $2
project_prefix = $3
project_identifier = $4
prefix = $5
sep = $7 || $9
identifier = $8 || $10
link = nil
if project_identifier
project = Project.visible.find_by(identifier: project_identifier)
end
if esc.nil?
if prefix.nil? && sep == 'r'
# project.changesets.visible raises an SQL error because of a double join on repositories
if project && project.repository && (changeset = Changeset.visible.find_by(repository_id: project.repository.id, revision: identifier))
link = link_to(h("#{project_prefix}r#{identifier}"), { only_path: only_path, controller: '/repositories', action: 'revision', project_id: project, rev: changeset.revision },
class: 'changeset',
title: truncate_single_line(changeset.comments, length: 100))
end
elsif sep == '#'
oid = identifier.to_i
case prefix
when nil
if work_package = WorkPackage.visible
.includes(:status)
.references(:statuses)
.find_by(id: oid)
link = link_to("##{oid}",
work_package_path_or_url(id: oid, only_path: only_path),
class: work_package_css_classes(work_package),
title: "#{truncate(work_package.subject, length: 100)} (#{work_package.status.try(:name)})")
end
when 'version'
if version = Version.visible.find_by(id: oid)
link = link_to h(version.name), { only_path: only_path, controller: '/versions', action: 'show', id: version },
class: 'version'
end
when 'message'
if message = Message.visible.includes(:parent).find_by(id: oid)
link = link_to_message(message, { only_path: only_path }, class: 'message')
end
when 'project'
if p = Project.visible.find_by(id: oid)
link = link_to_project(p, { only_path: only_path }, class: 'project')
end
when 'user'
if user = User.in_visible_project.find_by(id: oid)
link = link_to_user(user, class: 'user-mention')
end
end
elsif sep == '##'
oid = identifier.to_i
if work_package = WorkPackage.visible
.includes(:status)
.references(:statuses)
.find_by(id: oid)
link = work_package_quick_info(work_package, only_path: only_path)
end
elsif sep == '###'
oid = identifier.to_i
work_package = WorkPackage.visible
.includes(:status)
.references(:statuses)
.find_by(id: oid)
if work_package && obj && !(attr == :description && obj.id == work_package.id)
link = work_package_quick_info_with_description(work_package, only_path: only_path)
end
elsif sep == ':'
# removes the double quotes if any
name = identifier.gsub(%r{\A"(.*)"\z}, '\\1')
case prefix
when 'version'
if project && version = project.versions.visible.find_by(name: name)
link = link_to h(version.name), { only_path: only_path, controller: '/versions', action: 'show', id: version },
class: 'version'
end
when 'commit'
if project && project.repository && (changeset = Changeset.visible.where(['repository_id = ? AND scmid LIKE ?', project.repository.id, "#{name}%"]).first)
link = link_to h("#{project_prefix}#{name}"), { only_path: only_path, controller: '/repositories', action: 'revision', project_id: project, rev: changeset.identifier },
class: 'changeset',
title: truncate_single_line(changeset.comments, length: 100)
end
when 'source', 'export'
if project && project.repository && User.current.allowed_to?(:browse_repository, project)
name =~ %r{\A[/\\]*(.*?)(@([0-9a-f]+))?(#(L\d+))?\z}
path = $1
rev = $3
anchor = $5
link = link_to h("#{project_prefix}#{prefix}:#{name}"), { controller: '/repositories', action: 'entry', project_id: project,
path: path.to_s,
rev: rev,
anchor: anchor,
format: (prefix == 'export' ? 'raw' : nil) },
class: (prefix == 'export' ? 'source download' : 'source')
end
when 'attachment'
attachments = options[:attachments] || (obj && obj.respond_to?(:attachments) ? obj.attachments : nil)
if attachments && attachment = attachments.detect { |a| a.filename == name }
link = link_to h(attachment.filename), { only_path: only_path, controller: '/attachments', action: 'download', id: attachment },
class: 'attachment'
end
when 'project'
p = Project
.visible
.where(['projects.identifier = :s OR LOWER(projects.name) = :s',
{ s: name.downcase }])
.first
if p
link = link_to_project(p, { only_path: only_path }, class: 'project')
end
when 'user'
if user = User.in_visible_project.find_by(login: name)
link = link_to_user(user, class: 'user-mention')
end
end
end
end
leading + (link || "#{project_prefix}#{prefix}#{sep}#{identifier}")
end
end
HEADING_RE = /<h(1|2|3|4)( [^>]+)?>(.+?)<\/h(1|2|3|4)>/i unless const_defined?(:HEADING_RE)
# Headings and TOC
# Adds ids and links to headings unless options[:headings] is set to false
def parse_headings(text, _project, _obj, _attr, _only_path, options)
return if options[:headings] == false
text.gsub!(HEADING_RE) do
level = $1.to_i
attrs = $2
content = $3
item = strip_tags(content).strip
tocitem = strip_tags(content.gsub(/<br \/>/, ' '))
anchor = item.gsub(%r{[^\w\s\-]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
@parsed_headings << [level, anchor, tocitem]
url = full_url(anchor, options[:request])
"<a name=\"#{anchor}\"></a>\n<h#{level} #{attrs}>#{content}<a href=\"#{url}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
end
end
TOC_RE = /<p>\{\{([<>]?)toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
# Renders the TOC with given headings
def replace_toc(text, headings, options)
text.gsub!(TOC_RE) do
if headings.empty?
''
else
div_class = 'toc'
div_class << ' right' if $1 == '>'
div_class << ' left' if $1 == '<'
out = "<fieldset class='form--fieldset -collapsible'>"
out << "<legend class='form--fieldset-legend' title='" +
l(:description_toc_toggle) +
"' onclick='toggleFieldset(this);'>
<a href='javascript:'>
#{l(:label_table_of_contents)}
</a>
</legend><div>"
out << "<ul class=\"#{div_class}\"><li>"
root = headings.map(&:first).min
current = root
started = false
headings.each do |level, anchor, item|
if level > current
out << '<ul><li>' * (level - current)
elsif level < current
out << "</li></ul>\n" * (current - level) + '</li><li>'
elsif started
out << '</li><li>'
end
url = full_url(anchor, options[:request])
out << "<a href=\"#{url}\">#{item}</a>"
current = level
started = true
end
out << '</li></ul>' * (current - root)
out << '</li></ul>'
out << '</div></fieldset>'
end
end
end
#
# displays the current url plus an optional anchor
#
def full_url(anchor_name = '', current_request = nil)
return "##{anchor_name}" if current_request.nil?
current = current_request.original_fullpath
return current if anchor_name.blank?
"#{current}##{anchor_name}"
end
end
end
end

@ -0,0 +1,143 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2017 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.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'redcloth3'
module OpenProject::TextFormatting::Formatters
module Textile
class RedclothWrapper < RedCloth3
include ERB::Util
include ActionView::Helpers::TagHelper
# auto_link rule after textile rules so that it doesn't break !image_url! tags
RULES = [:textile, :block_markdown_rule, :inline_auto_link, :inline_auto_mailto]
def initialize(*args)
super
self.hard_breaks = true
self.no_span_caps = true
self.filter_styles = true
end
def to_html(*_rules)
@toc = []
super(*RULES).to_s
end
private
# Patch for RedCloth. Fixed in RedCloth r128 but _why hasn't released it yet.
# <a href="http://code.whytheluckystiff.net/redcloth/changeset/128">http://code.whytheluckystiff.net/redcloth/changeset/128</a>
def hard_break(text)
text.gsub!(/(.)\n(?!\n|\Z| *([#*=]+(\s|$)|[{|]))/, '\\1<br />') if hard_breaks
end
# Patch to add code highlighting support to RedCloth
def smooth_offtags(text)
unless @pre_list.empty?
## replace <pre> content
text.gsub!(/<redpre#(\d+)>/) do
content = @pre_list[$1.to_i]
if content.match(/<code\s+class="(\w+)">\s?(.+)/m)
content = "<code class=\"#{$1} CodeRay\">" +
Redmine::SyntaxHighlighting.highlight_by_language($2, $1)
end
content
end
end
end
def auto_link_regexp
@auto_link_regexp ||= begin
%r{
( # leading text
<\w+.*?>| # leading HTML tag, or
[^=<>!:'"/]| # leading punctuation, or
\{\{\w+\(| # inside a macro?
^ # beginning of line
)
(
(?:https?://)| # protocol spec, or
(?:s?ftps?://)|
(?:www\.) # www.*
)
(
(\S+?) # url
(\/)? # slash
)
((?:&gt;)?|[^\w\=\/;\(\)]*?) # post
(?=<|\s|$)
}x
end
end
# Turns all urls into clickable links (code from Rails).
def inline_auto_link(text)
text.gsub!(auto_link_regexp) do
all = $&
leading = $1
proto = $2
url = $3
post = $6
if url.nil? || leading =~ /<a\s/i || leading =~ /![<>=]?/ || leading =~ /\{\{\w+\(/
# don't replace URLs that are already linked
# and URLs prefixed with ! !> !< != (textile images)
all
else
# Idea below : an URL with unbalanced parethesis and
# ending by ')' is put into external parenthesis
if url[-1] == ?) and ((url.count('(') - url.count(')')) < 0)
url = url[0..-2] # discard closing parenth from url
post = ')' + post # add closing parenth to post
end
tag = content_tag('a',
proto + url,
href: "#{proto == 'www.' ? 'http://www.' : proto}#{url}",
class: 'external icon-context icon-copy')
%(#{leading}#{tag}#{post})
end
end
end
# Turns all email addresses into clickable links (code from Rails).
def inline_auto_mailto(text)
text.gsub!(/((?<!user:")\b[\w\.!#\$%\-+.]+@[A-Za-z0-9\-]+(\.[A-Za-z0-9\-]+)+)/) do
mail = $1
if text.match(/<a\b[^>]*>(.*)(#{Regexp.escape(mail)})(.*)<\/a>/)
mail
else
content_tag('a', mail, href: "mailto:#{mail}", class: 'email')
end
end
end
end
end
end

@ -37,20 +37,8 @@ module OpenProject::TextFormatting
# offer 'plain' as readable version for 'no formatting' to callers
format = options.fetch(:format, Setting.text_formatting)
# Forward to the legacy text formatting for textile syntax
if format == 'textile'
return OpenProject::TextFormatting::Formatters::Textile::LegacyTextFormatting
.new(options[:project])
.format_text(text, options)
end
# Get the associated formatter
pipeline = OpenProject::TextFormatting::Pipeline.new(
format,
context: options
)
pipeline.to_html(text)
formatter = OpenProject::TextFormatting::Formatters.formatter_for(format).new(options)
formatter.to_html(text)
end
end
end

@ -0,0 +1,46 @@
# Textile to Markdown converter
# Based on redmine_convert_textile_to_markown
# https://github.com/Ecodev/redmine_convert_textile_to_markown
#
# Original license:
# Copyright (c) 2016
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
namespace :markdown do
task :convert_from_textile => :environment do
warning = <<~EOS
**WARNING**
THIS IS NOT REVERSIBLE.
Ensure you have backed up your installation before running this task.
This rake task will modify EVERY formattable textile field in your database.
It uses pandoc to convert each textile field to GFM-Markdown.
EOS
printf "#{warning}\nPress 'y' to continue: "
prompt = STDIN.gets.chomp
exit(1) unless prompt == 'y'
converter = OpenProject::TextFormatting::Formatters::Markdown::TextileConverter.new
converter.run!
end
end

@ -86,7 +86,7 @@ describe 'Angular expression escaping', type: :feature do
)
}
let(:user) { FactoryGirl.create :admin }
let(:field) { WorkPackageTextAreaField.new wp_page, 'description' }
let(:field) { WorkPackageEditorField.new wp_page, 'description' }
let(:wp_page) { Pages::SplitWorkPackage.new(work_package, project) }
before do

@ -13,9 +13,9 @@ describe 'activity comments', js: true, selenium: true do
let(:wp_page) { Pages::SplitWorkPackage.new(work_package, project) }
let(:selector) { '.work-packages--activity--add-comment' }
let(:comment_field) {
WorkPackageTextAreaField.new wp_page,
'comment',
selector: selector
WorkPackageEditorField.new wp_page,
'comment',
selector: selector
}
let(:initial_comment) { 'the first comment in this WP' }
@ -167,7 +167,7 @@ describe 'activity comments', js: true, selenium: true do
# Check the edit textarea
activity.find('.icon-edit').click
edit = WorkPackageTextAreaField.new wp_page,
edit = WorkPackageEditorField.new wp_page,
'comment',
selector: '.user-comment--form'
@ -184,7 +184,7 @@ describe 'activity comments', js: true, selenium: true do
# Check the edit textarea
activity.find('.icon-edit').click
edit = WorkPackageTextAreaField.new wp_page,
edit = WorkPackageEditorField.new wp_page,
'comment',
selector: '.user-comment--form'

@ -43,7 +43,7 @@ describe 'custom field inplace editor', js: true do
FactoryGirl.create(:text_issue_custom_field, name: 'LongText')
}
let(:initial_custom_values) { { custom_field.id => 'foo' } }
let(:field) { WorkPackageTextAreaField.new wp_page, :customField1 }
let(:field) { WorkPackageEditorField.new wp_page, :customField1 }
it 'can cancel through the button only' do
# Activate the field

@ -17,7 +17,7 @@ describe 'description inplace editor', js: true, selenium: true do
)
}
let(:user) { FactoryGirl.create :admin }
let(:field) { WorkPackageTextAreaField.new wp_page, 'description' }
let(:field) { WorkPackageEditorField.new wp_page, 'description' }
let(:wp_page) { Pages::SplitWorkPackage.new(work_package, project) }
before do

@ -97,7 +97,7 @@ shared_examples 'a cancellable field' do
end
shared_examples 'a previewable field' do
it 'can preview the field' do
xit 'can preview the field' do
field.activate!
field.input_element.set '*Highlight*'
@ -116,7 +116,7 @@ end
shared_examples 'a workpackage autocomplete field' do
let!(:wp2) { FactoryGirl.create(:work_package, project: project, subject: 'AutoFoo') }
it 'autocompletes the other work package' do
xit 'autocompletes the other work package' do
field.activate!
field.input_element.send_keys(" ##{wp2.id}")
expect(page).to have_selector('.atwho-view-ul li.cur', text: wp2.to_s.strip)

@ -37,7 +37,7 @@ describe 'Parallel work package creation spec', js: true do
# Create in split screen
split = wp_table.create_wp_split_screen type.name
description_field = WorkPackageTextAreaField.new split, 'description'
description_field = WorkPackageEditorField.new split, 'description'
description_field.expect_active!
description_field.set_value description
end

@ -147,17 +147,16 @@ describe 'Activity tab', js: true, selenium: true do
it 'can quote a previous comment' do
activity_tab.hover_action('1', :quote)
field = WorkPackageTextAreaField.new work_package_page,
field = WorkPackageEditorField.new work_package_page,
'comment',
selector: '.work-packages--activity--add-comment'
expect(field.editing?).to be true
# Add our comment
quote = field.input_element[:value]
expect(quote).to include("> #{initial_comment}")
quote << "\nthis is some remark under a quote"
field.input_element.set(quote)
quote = field.input_element[:innerHTML]
expect(quote).to have_selector('p', text: 'Anonymous wrote:')
expect(quote).to have_selector('blockquote', text: 'First comment on this wp.')
field.submit_by_click
expect(page).to have_selector('.user-comment > .message', count: 3)

@ -27,12 +27,10 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
require_relative '../../../../legacy_spec_helper'
require 'spec_helper'
describe Redmine::WikiFormatting::NullFormatter::Formatter do
before do
@formatter = Redmine::WikiFormatting::NullFormatter::Formatter
end
describe OpenProject::TextFormatting::Formatters::Plain::Formatter do
subject { described_class.new({}) }
it 'should plain text' do
assert_html_output('This is some input' => 'This is some input')
@ -48,11 +46,11 @@ describe Redmine::WikiFormatting::NullFormatter::Formatter do
def assert_html_output(to_test, expect_paragraph = true)
to_test.each do |text, expected|
assert_equal((expect_paragraph ? "<p>#{expected}</p>" : expected), @formatter.new(text).to_html, "Formatting the following text failed:\n===\n#{text}\n===\n")
assert_equal((expect_paragraph ? "<p>#{expected}</p>" : expected), subject.to_html(text), "Formatting the following text failed:\n===\n#{text}\n===\n")
end
end
def to_html(text)
@formatter.new(text).to_html
subject.to_html(text)
end
end

@ -27,15 +27,9 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
require_relative '../../../../legacy_spec_helper'
describe Redmine::WikiFormatting::Textile::Formatter do
include Rails::Dom::Testing::Assertions
before do
@formatter = Redmine::WikiFormatting::Textile::Formatter
end
require 'spec_helper'
describe OpenProject::TextFormatting::Formatters::Textile::RedclothWrapper do
MODIFIERS = {
'*' => 'strong', # bold
'_' => 'em', # italic
@ -167,7 +161,7 @@ Nullam commodo metus accumsan nulla. Curabitur lobortis dui id dolor.
<p>He's right.</p>
EXPECTED
assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '')
expect(expected.gsub(%r{\s+}, '')).to eq(to_html(raw).gsub(%r{\s+}, ''))
end
it 'should table' do
@ -189,7 +183,7 @@ RAW
</table>
EXPECTED
assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '')
expect(expected.gsub(%r{\s+}, '')).to eq(to_html(raw).gsub(%r{\s+}, ''))
end
it 'should table with line breaks' do
@ -228,11 +222,11 @@ RAW
</table>
EXPECTED
assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '')
expect(expected.gsub(%r{\s+}, '')).to eq(to_html(raw).gsub(%r{\s+}, ''))
end
it 'should textile should not mangle brackets' do
assert_equal '<p>[msg1][msg2]</p>', to_html('[msg1][msg2]')
expect(to_html('[msg1][msg2]')).to eq '<p>[msg1][msg2]</p>'
end
it 'should textile should escape image urls' do
@ -240,18 +234,19 @@ EXPECTED
raw = '!/images/comment.png"onclick=&#x61;&#x6c;&#x65;&#x72;&#x74;&#x28;&#x27;&#x58;&#x53;&#x53;&#x27;&#x29;;&#x22;!'
expected = '<p><img src="/images/comment.png&quot;onclick=&amp;#x61;&amp;#x6c;&amp;#x65;&amp;#x72;&amp;#x74;&amp;#x28;&amp;#x27;&amp;#x58;&amp;#x53;&amp;#x53;&amp;#x27;&amp;#x29;;&amp;#x22;" alt="" /></p>'
assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '')
expect(expected.gsub(%r{\s+}, '')).to eq(to_html(raw).gsub(%r{\s+}, ''))
end
private
def assert_html_output(to_test, expect_paragraph = true)
to_test.each do |text, expected|
assert_dom_equal((expect_paragraph ? "<p>#{expected}</p>" : expected), @formatter.new(text).to_html, "Formatting the following text failed:\n===\n#{text}\n===\n")
expected = expect_paragraph ? "<p>#{expected}</p>" : expected
expect(to_html(text)).to be_html_eql expected
end
end
def to_html(text)
@formatter.new(text).to_html
described_class.new(text).to_html
end
end

@ -26,17 +26,25 @@
#
# See docs/COPYRIGHT.rdoc for more details.
#++
require 'legacy_spec_helper'
require 'spec_helper'
describe OpenProject::WikiFormatting do
it 'should markdown formatter' do
expect(OpenProject::TextFormatting::Formatters::Markdown::Formatter).to eq(OpenProject::TextFormatting::Formatters.formatter_for('markdown'))
expect(OpenProject::TextFormatting::Formatters::Markdown::Helper).to eq(OpenProject::TextFormatting::Formatters.helper_for('markdown'))
end
describe Redmine::WikiFormatting do
it 'should textile formatter' do
assert_equal Redmine::WikiFormatting::Textile::Formatter, Redmine::WikiFormatting.formatter_for('textile')
assert_equal Redmine::WikiFormatting::Textile::Helper, Redmine::WikiFormatting.helper_for('textile')
expect(OpenProject::TextFormatting::Formatters::Textile::Formatter).to eq(OpenProject::TextFormatting::Formatters.formatter_for('textile'))
expect(OpenProject::TextFormatting::Formatters::Textile::Helper).to eq(OpenProject::TextFormatting::Formatters.helper_for('textile'))
end
it 'should null formatter' do
assert_equal Redmine::WikiFormatting::NullFormatter::Formatter, Redmine::WikiFormatting.formatter_for('')
assert_equal Redmine::WikiFormatting::NullFormatter::Helper, Redmine::WikiFormatting.helper_for('')
it 'should plain formatter' do
expect(OpenProject::TextFormatting::Formatters::Plain::Formatter).to eq(OpenProject::TextFormatting::Formatters.formatter_for('plain'))
expect(OpenProject::TextFormatting::Formatters::Plain::Helper).to eq(OpenProject::TextFormatting::Formatters.helper_for('plain'))
expect(OpenProject::TextFormatting::Formatters::Plain::Formatter).to eq(OpenProject::TextFormatting::Formatters.formatter_for('doesnotexist'))
expect(OpenProject::TextFormatting::Formatters::Plain::Helper).to eq(OpenProject::TextFormatting::Formatters.helper_for('doesnotexist'))
end
it 'should link urls and email addresses' do
@ -46,10 +54,10 @@ and an email address foo@example.net
DIFF
expected = <<-EXPECTED
<p>This is a sample *text* with a link: <a href="http://www.redmine.org">http://www.redmine.org</a><br />
<p>This is a sample *text* with a link: <a href="http://www.redmine.org">http://www.redmine.org</a><br>
and an email address <a href="mailto:foo@example.net">foo@example.net</a></p>
EXPECTED
assert_equal expected.gsub(%r{[\r\n\t]}, ''), Redmine::WikiFormatting::NullFormatter::Formatter.new(raw).to_html.gsub(%r{[\r\n\t]}, '')
assert_equal expected.gsub(%r{[\r\n\t]}, ''),OpenProject::TextFormatting::Formatters::Plain::Formatter.new({}).to_html(raw).gsub(%r{[\r\n\t]}, '')
end
end

@ -154,12 +154,12 @@ module Pages
cf = CustomField.find $1
if cf.field_format == 'text'
WorkPackageTextAreaField.new page, key
WorkPackageEditorField.new page, key
else
WorkPackageField.new page, key
end
elsif key == :description
WorkPackageTextAreaField.new page, key
WorkPackageEditorField.new page, key
elsif key == :status
WorkPackageStatusField.new page
else

@ -0,0 +1,49 @@
require_relative './work_package_field'
class WorkPackageEditorField < WorkPackageField
def input_selector
'div.op-ckeditor-wrapper'
end
def expect_save_button(enabled: true)
if enabled
expect(field_container).to have_no_selector("#{control_link}[disabled]")
else
expect(field_container).to have_selector("#{control_link}[disabled]")
end
end
def expect_value(value)
expect(input_element.text).to eq(value)
end
def save!
submit_by_click
end
def submit_by_click
target = field_container.find(control_link)
scroll_to_element(target)
target.click
end
def submit_by_keyboard
input_element.native.send_keys :tab
end
def cancel_by_click
target = field_container.find(control_link(:cancel))
scroll_to_element(target)
target.click
end
def field_type
input_selector
end
def control_link(action = :save)
raise 'Invalid link' unless [:save, :cancel].include?(action)
".inplace-edit--control--#{action}"
end
end

@ -102,55 +102,11 @@ describe UserMailer, type: :mailer do
end
end
it 'should generated links with prefix' do
Setting.default_language = 'en'
Setting.host_name = 'mydomain.foo/rdm'
Setting.protocol = 'http'
project, user, related_issue, issue, changeset, attachment, journal = setup_complex_issue_update
assert UserMailer.work_package_updated(user, journal).deliver_now
assert last_email
assert_select_email do
# link to the main ticket
assert_select 'a[href=?]',
"http://mydomain.foo/rdm/work_packages/#{issue.id}",
text: "My Type ##{issue.id}: My awesome Ticket"
# link to a description diff
assert_select 'li', text: /Description changed/
assert_select 'li>a[href=?]',
"http://mydomain.foo/rdm/journals/#{journal.id}/diff/description",
text: 'Details'
# link to a referenced ticket
assert_select 'a[href=?][title=?]',
"http://mydomain.foo/rdm/work_packages/#{related_issue.id}",
"My related Ticket (#{related_issue.status})",
text: "##{related_issue.id}"
# link to a changeset
if changeset
assert_select 'a[href=?][title=?]',
url_for(controller: 'repositories',
action: 'revision',
project_id: project,
rev: changeset.revision),
'This commit fixes #1, #2 and references #1 and #3',
text: "r#{changeset.revision}"
end
# link to an attachment
assert_select 'a[href=?]',
"http://mydomain.foo/rdm/attachments/#{attachment.id}/#{attachment.filename}",
text: "#{attachment.filename}"
end
end
it 'should generated links with prefix and no relative url root' do
begin
context 'with prefix', with_config: { rails_relative_url_root: '/rdm' } do
it 'should generated links with prefix and relative url root' do
Setting.default_language = 'en'
relative_url_root = OpenProject::Configuration['rails_relative_url_root']
Setting.host_name = 'mydomain.foo/rdm'
Setting.host_name = 'mydomain.foo'
Setting.protocol = 'http'
OpenProject::Configuration['rails_relative_url_root'] = nil
project, user, related_issue, issue, changeset, attachment, journal = setup_complex_issue_update
@ -176,6 +132,7 @@ describe UserMailer, type: :mailer do
if changeset
assert_select 'a[href=?][title=?]',
url_for(controller: 'repositories',
script_name: '/rdm',
action: 'revision',
project_id: project,
rev: changeset.revision),
@ -187,9 +144,6 @@ describe UserMailer, type: :mailer do
"http://mydomain.foo/rdm/attachments/#{attachment.id}/#{attachment.filename}",
text: "#{attachment.filename}"
end
ensure
# restore it
OpenProject::Configuration['rails_relative_url_root'] = relative_url_root
end
end

Loading…
Cancel
Save