mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
CKEditor5 config and bundle for OP
This commit is contained in:
@@ -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
|
||||
|
||||
+9
-2
@@ -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: '@'
|
||||
}
|
||||
});
|
||||
-17
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+9
-3
@@ -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
|
||||
========================================
|
||||
|
||||
[](https://gitter.im/ckeditor/ckeditor5?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
||||
[](https://www.npmjs.com/package/@ckeditor/ckeditor5-block-quote)
|
||||
[](https://travis-ci.org/ckeditor/ckeditor5-block-quote)
|
||||
[](https://codeclimate.com/github/ckeditor/ckeditor5-block-quote/coverage)
|
||||
[](https://david-dm.org/ckeditor/ckeditor5-block-quote)
|
||||
[](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"
|
||||
]
|
||||
}
|
||||
Generated
+1649
-2495
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
|
||||
|
||||
+10
-22
@@ -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
|
||||
module OpenProject::TextFormatting::Formatters
|
||||
class Base
|
||||
attr_reader :options, :project
|
||||
|
||||
def initialize(formatter, context:)
|
||||
@formatter = formatter
|
||||
@context = context
|
||||
|
||||
@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)> /, "\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
|
||||
|
||||
# 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 controller; end
|
||||
|
||||
def initialize(*args)
|
||||
super
|
||||
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
|
||||
|
||||
self.hard_breaks = true
|
||||
self.no_span_caps = true
|
||||
self.filter_styles = true
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
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
|
||||
content
|
||||
parsed << full_tag
|
||||
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
|
||||
)
|
||||
((?:>)?|[^\w\=\/;\(\)]*?) # post
|
||||
(?=<|\s|$)
|
||||
}x
|
||||
# Close any non closing tags
|
||||
while tag = tags.pop
|
||||
parsed << "</#{tag}>"
|
||||
end
|
||||
parsed
|
||||
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)
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
content_tag('a', mail, href: "mailto:#{mail}", class: 'email')
|
||||
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\">¶</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
|
||||
|
||||
@@ -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\">¶</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
|
||||
)
|
||||
((?:>)?|[^\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)
|
||||
|
||||
+5
-7
@@ -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
|
||||
+10
-15
@@ -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=alert('XSS');"!'
|
||||
expected = '<p><img src="/images/comment.png"onclick=&#x61;&#x6c;&#x65;&#x72;&#x74;&#x28;&#x27;&#x58;&#x53;&#x53;&#x27;&#x29;;&#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
|
||||
+18
-10
@@ -26,17 +26,25 @@
|
||||
#
|
||||
# See docs/COPYRIGHT.rdoc for more details.
|
||||
#++
|
||||
require 'legacy_spec_helper'
|
||||
require 'spec_helper'
|
||||
|
||||
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')
|
||||
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
|
||||
|
||||
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 textile formatter' do
|
||||
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 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user