diff --git a/app/assets/javascripts/application-legacy.js b/app/assets/javascripts/application-legacy.js
index 3ee2e4335..833e7d8d1 100644
--- a/app/assets/javascripts/application-legacy.js
+++ b/app/assets/javascripts/application-legacy.js
@@ -1170,7 +1170,6 @@ $(document).ready(function(){
data: "text=" + element + '&' + attachments,
success: function(data){
jstBlock.find('.wiki-preview').html(data);
- setupWikiTableSortableHeader();
}
});
});
@@ -1439,7 +1438,6 @@ $(document).ready(defaultFocus);
$(document).ready(setupAttachmentDetail);
$(document).ready(setupTabs);
$(document).ready(setupFilePreviewNavigation);
-$(document).ready(setupWikiTableSortableHeader);
$(document).on('focus', '[data-auto-complete=true]', function(event) {
inlineAutoComplete(event.target);
});
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 6ea148f2d..df3bc8ddc 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -1801,9 +1801,6 @@ module ApplicationHelper
'rails-ujs',
'tribute-5.1.3.min'
)
- if Setting.wiki_tablesort_enabled?
- tags << javascript_include_tag('tablesort-5.2.1.min.js', 'tablesort-5.2.1.number.min.js')
- end
tags << javascript_include_tag('application-legacy', 'responsive')
unless User.current.pref.warn_on_leaving_unsaved == '0'
warn_text = escape_javascript(l(:text_warn_on_leaving_unsaved))
diff --git a/app/javascript/controllers/tablesort_controller.js b/app/javascript/controllers/tablesort_controller.js
new file mode 100644
index 000000000..12db0789a
--- /dev/null
+++ b/app/javascript/controllers/tablesort_controller.js
@@ -0,0 +1,18 @@
+/**
+ * Redmine - project management software
+ * Copyright (C) 2006- Jean-Philippe Lang
+ * This code is released under the GNU General Public License.
+ */
+import { Controller } from "@hotwired/stimulus"
+import Tablesort from 'tablesort';
+import numberPlugin from 'tablesort.number';
+
+// Extensions must be loaded explicitly
+Tablesort.extend(numberPlugin.name, numberPlugin.pattern, numberPlugin.sort);
+
+// Connects to data-controller="tablesort"
+export default class extends Controller {
+ connect() {
+ new Tablesort(this.element);
+ }
+}
diff --git a/app/views/journals/update.js.erb b/app/views/journals/update.js.erb
index cf6bcd28f..218a164de 100644
--- a/app/views/journals/update.js.erb
+++ b/app/views/journals/update.js.erb
@@ -14,7 +14,6 @@
} else {
journal_header.append('<%= escape_javascript(render_journal_update_info(@journal)) %>');
}
- setupWikiTableSortableHeader();
setupCopyButtonsToPreElements();
setupHoverTooltips();
<% end %>
diff --git a/config/importmap.rb b/config/importmap.rb
index 3560330c1..795429f02 100644
--- a/config/importmap.rb
+++ b/config/importmap.rb
@@ -7,3 +7,5 @@ pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin "turndown" # @7.2.0
pin_all_from "app/javascript/controllers", under: "controllers"
+pin "tablesort", to: "tablesort.min.js"
+pin "tablesort.number", to: "tablesort.number.min.js"
diff --git a/lib/redmine/wiki_formatting/common_mark/formatter.rb b/lib/redmine/wiki_formatting/common_mark/formatter.rb
index 0a5137209..d9d3e9640 100644
--- a/lib/redmine/wiki_formatting/common_mark/formatter.rb
+++ b/lib/redmine/wiki_formatting/common_mark/formatter.rb
@@ -55,6 +55,7 @@ module Redmine
SANITIZER = SanitizationFilter.new
SCRUBBERS = [
SyntaxHighlightScrubber.new,
+ Redmine::WikiFormatting::TablesortScrubber.new,
FixupAutoLinksScrubber.new,
ExternalLinksScrubber.new,
AlertsIconsScrubber.new
diff --git a/lib/redmine/wiki_formatting/tablesort_scrubber.rb b/lib/redmine/wiki_formatting/tablesort_scrubber.rb
new file mode 100644
index 000000000..01f368a8a
--- /dev/null
+++ b/lib/redmine/wiki_formatting/tablesort_scrubber.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+# This code is released under the GNU General Public License.
+
+module Redmine
+ module WikiFormatting
+ class TablesortScrubber < Loofah::Scrubber
+ def scrub(node)
+ return if !Setting.wiki_tablesort_enabled? || node.name != 'table'
+
+ rows = node.search('tr')
+ return if rows.size < 3
+
+ tr = rows.first
+ if tr.search('th').present?
+ node['data-controller'] = 'tablesort'
+ tr['data-sort-method'] = 'none'
+ tr.search('td').each do |td|
+ td['data-sort-method'] = 'none'
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/redmine/wiki_formatting/textile/formatter.rb b/lib/redmine/wiki_formatting/textile/formatter.rb
index cfb941711..462301461 100644
--- a/lib/redmine/wiki_formatting/textile/formatter.rb
+++ b/lib/redmine/wiki_formatting/textile/formatter.rb
@@ -20,11 +20,33 @@
module Redmine
module WikiFormatting
module Textile
- class Formatter < RedCloth3
- include ActionView::Helpers::TagHelper
- include Redmine::WikiFormatting::LinksHelper
+ SCRUBBERS = [
+ Redmine::WikiFormatting::TablesortScrubber.new
+ ]
+
+ class Formatter
include Redmine::WikiFormatting::SectionHelper
+ extend Forwardable
+ def_delegators :@filter, :extract_sections, :rip_offtags
+
+ def initialize(args)
+ @filter = Filter.new(args)
+ end
+
+ def to_html(*rules)
+ html = @filter.to_html(rules)
+ fragment = Loofah.html5_fragment(html)
+ SCRUBBERS.each do |scrubber|
+ fragment.scrub!(scrubber)
+ end
+ fragment.to_s
+ end
+ end
+
+ class Filter < RedCloth3
+ include Redmine::WikiFormatting::LinksHelper
+
alias :inline_auto_link :auto_link!
alias :inline_auto_mailto :auto_mailto!
alias :inline_restore_redmine_links :restore_redmine_links
@@ -41,7 +63,7 @@ module Redmine
def to_html(*rules)
@toc = []
- super(*RULES).to_s
+ super(*RULES)
end
def extract_sections(index)
diff --git a/test/helpers/application_helper_test.rb b/test/helpers/application_helper_test.rb
index 92c2cd1d9..4d0834773 100644
--- a/test/helpers/application_helper_test.rb
+++ b/test/helpers/application_helper_test.rb
@@ -80,7 +80,7 @@ class ApplicationHelperTest < Redmine::HelperTest
'(see inline link).',
'www.foo.bar' => 'www.foo.bar',
'http://foo.bar/page?p=1&t=z&s=' =>
- 'http://foo.bar/page?p=1&t=z&s=',
+ 'http://foo.bar/page?p=1&t=z&s=',
'http://foo.bar/page#125' => 'http://foo.bar/page#125',
'http://foo@www.bar.com' => 'http://foo@www.bar.com',
'http://foo:bar@www.bar.com' => 'http://foo:bar@www.bar.com',
@@ -92,7 +92,7 @@ class ApplicationHelperTest < Redmine::HelperTest
'' \
'http://example.net/path!602815048C7B5C20!302.html',
# escaping
- 'http://foo"bar' => 'http://foo"bar',
+ 'http://foo"bar' => 'http://foo"bar',
# wrap in angle brackets
' #{result}
',
+ '!http://foo.bar/image.jpg!' => '
',
'floating !>http://foo.bar/image.jpg!' =>
- 'floating
',
+ 'floating
',
'with class !(some-class)http://foo.bar/image.jpg!' =>
- 'with class
',
+ 'with class
',
'with class !(wiki-class-foo)http://foo.bar/image.jpg!' =>
- 'with class
',
+ 'with class
',
'with style !{width:100px;height:100px}http://foo.bar/image.jpg!' =>
- 'with style
',
+ 'with style
',
'with title !http://foo.bar/image.jpg(This is a title)!' =>
- 'with title
',
+ 'with title
',
'with title !http://foo.bar/image.jpg(This is a double-quoted "title")!' =>
'with title
',
+ 'alt="This is a double-quoted "title"">',
'with query string !http://foo.bar/image.cgi?a=1&b=2!' =>
- 'with query string '
+ 'with query string
'
}
with_settings :text_formatting => 'textile' do
to_test.each {|text, result| assert_equal "
')
- assert textilizable(raw).include?('
')
+ assert textilizable(raw).include?('
')
+ assert textilizable(raw).include?('
')
end
end
def test_attached_images
to_test = {
'Inline image: !logo.gif!' =>
- 'Inline image:
',
+ 'Inline image:
',
'Inline image: !logo.GIF!' =>
- 'Inline image:
',
+ 'Inline image:
',
'Inline WebP image: !logo.webp!' =>
- 'Inline WebP image:
',
- 'No match: !ogo.gif!' => 'No match:
',
- 'No match: !ogo.GIF!' => 'No match: ',
+ 'Inline WebP image:
',
+ 'No match: !ogo.gif!' => 'No match:
',
+ 'No match: !ogo.GIF!' => 'No match: ',
# link image
'!logo.gif!:http://foo.bar/' =>
- '
',
+ '
',
}
attachments = Attachment.all
with_settings :text_formatting => 'textile' do
@@ -190,31 +190,31 @@ class ApplicationHelperTest < Redmine::HelperTest
attachments = Attachment.all
with_settings text_formatting: 'textile' do
# When alt text is set
- assert_match %r[],
+ assert_match %r[
],
textilizable('!logo.gif(alt text)!', attachments: attachments)
# When alt text and style are set
- assert_match %r[
],
+ assert_match %r[
],
textilizable('!{width:100px}logo.gif(alt text)!', attachments: attachments)
# When alt text is not set
- assert_match %r[
],
+ assert_match %r[
],
textilizable('!logo.gif!', attachments: attachments)
# When alt text is not set and the attachment has no description
- assert_match %r[
],
+ assert_match %r[
],
textilizable('!testfile.PNG!', attachments: attachments)
# When no matching attachments are found
- assert_match %r[
],
+ assert_match %r[
],
textilizable('!no-match.jpg!', attachments: attachments)
- assert_match %r[
],
+ assert_match %r[
],
textilizable('!no-match.jpg(alt text)!', attachments: attachments)
# When no attachment is registered
- assert_match %r[
],
+ assert_match %r[
],
textilizable('!logo.gif!', attachments: [])
- assert_match %r[
],
+ assert_match %r[
],
textilizable('!logo.gif(alt text)!', attachments: [])
end
end
@@ -232,8 +232,8 @@ class ApplicationHelperTest < Redmine::HelperTest
RAW
with_settings :text_formatting => 'textile' do
- assert textilizable(raw, :object => journal).include?("
")
- assert textilizable(raw, :object => journal).include?("
")
+ assert textilizable(raw, :object => journal).include?("
")
+ assert textilizable(raw, :object => journal).include?("
")
end
end
@@ -245,7 +245,7 @@ class ApplicationHelperTest < Redmine::HelperTest
with_settings :text_formatting => 'textile' do
to_test.each do |filename, result|
attachment = Attachment.generate!(:filename => filename)
- assert_include %(),
+ assert_include %(
),
textilizable("!#{filename}!", :attachments => [attachment])
end
end
@@ -272,7 +272,7 @@ class ApplicationHelperTest < Redmine::HelperTest
with_settings :text_formatting => 'textile' do
assert_equal(
%(

',
+ 'Inline image:
',
'Inline image: !testtest.jpe!' =>
- 'Inline image: Another paragraph",
# no multiline link text
"This is a double quote \"on the first line\nand another on a second line\":test" =>
- "This is a double quote \"on the first line
and another on a second line\":test",
+ "This is a double quote \"on the first line
and another on a second line\":test",
# mailto link
"\"system administrator\":mailto:sysadmin@example.com?subject=redmine%20permissions" =>
"system administrator",
@@ -391,7 +391,7 @@ class ApplicationHelperTest < Redmine::HelperTest
'(see "inline link":http://www.foo.bar/Test-)' =>
'(see inline link)',
'http://foo.bar/page?p=1&t=z&s=-' =>
- 'http://foo.bar/page?p=1&t=z&s=-',
+ 'http://foo.bar/page?p=1&t=z&s=-',
'This is an intern "link":/foo/bar-' => 'This is an intern link'
}
with_settings :text_formatting => 'textile' do
@@ -1317,10 +1317,10 @@ class ApplicationHelperTest < Redmine::HelperTest
def test_html_tags
to_test = {
"
<div>content</div>
", - "<div class="bold">content</div>
", + "<div class=\"bold\">content</div>
", "" => "<script>some script;</script>
", # do not escape pre/code tags - "\nline 1\nline2" => "
\nline 1\nline2", + "
\nline 1\nline2" => "
line 1\nline2", "
\nline 1\nline2" => "\nline 1\nline2",
"" => "content
<div class=\"foo\">content</div>", "
" => "content
<div class=\"<foo\">content</div>", @@ -1477,7 +1477,7 @@ class ApplicationHelperTest < Redmine::HelperTest "
Dashes: ---
', textilizable('Dashes: ---') end end diff --git a/test/unit/lib/redmine/wiki_formatting/tablesort_scrubber_test.rb b/test/unit/lib/redmine/wiki_formatting/tablesort_scrubber_test.rb new file mode 100644 index 000000000..8144309c0 --- /dev/null +++ b/test/unit/lib/redmine/wiki_formatting/tablesort_scrubber_test.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# This code is released under the GNU General Public License. + +require_relative '../../../../test_helper' + +class Redmine::WikiFormatting::TablesortScrubberTest < ActiveSupport::TestCase + def filter(html) + fragment = Redmine::WikiFormatting::HtmlParser.parse(html) + scrubber = Redmine::WikiFormatting::TablesortScrubber.new + fragment.scrub!(scrubber) + fragment.to_s + end + + test 'should not add data-controller attribute by default' do + table = <<~HTML +| A | +B | +
|---|---|
| + | + |
| + | + |
| A | +B | +
|---|---|
| + | + |
| A | +B | +
|---|---|
| + | + |
| + | + |
| A | +B | +
|---|---|
| + | + |
| + | + |
John said:
- Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas sed libero.
+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas sed libero.
Nullam commodo metus accumsan nulla. Curabitur lobortis dui id dolor.
- Donec odio lorem,
@@ -282,9 +282,11 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCaseThis is a table with empty cells:
+
EXPECTED assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '') @@ -298,9 +300,11 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase RAW expected = <<~EXPECTEDcell11 cell12 cell21 cell23 + cell31 cell32 cell33 +
EXPECTED assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '') @@ -318,9 +322,11 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCaseright left + justify This is a table with trailing whitespace in one row:
+
EXPECTED assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '') @@ -343,21 +349,23 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCasecell11 cell12 cell21 cell22 + cell31 cell32 This is a table with line breaks:
+
EXPECTED assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '') @@ -380,18 +388,20 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase- cell11 +
continuedcell11
continuedcell12 cell21- cell23 +
cell23 line2
cell23 line3cell23
cell23 line2
cell23 line3+ cell31 -cell32 +
cell32 line2cell32
cell32 line2cell33 This is a table with lists:
+
EXPECTED assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '') @@ -408,7 +418,7 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase expected = 'cell11 cell12 cell21 -ordered list +
# item
# item 2ordered list
# item
# item 2+ cell31 -unordered list +
* item
* item 2unordered list
* item
* item 2' + ''XSS');"" alt="">' assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '') end @@ -635,7 +645,7 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase "
unsupported language" => "unsupported language", "special-char language" => - "special-char language", + "special-char language", }, false ) @@ -652,11 +662,11 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase def test_should_prefix_class_attribute_on_tags assert_html_output( { - '!(foo)test.png!' => "", + '!(foo)test.png!' => "
", '%(foo)test%' => "
test
", 'p(foo). test' => "test
", '|(foo). test|' => - "\n\t\t
", + "\n\t\t\t \n\ttest \n\t\t\n\t\t
", }, false ) @@ -665,11 +675,11 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase def test_should_prefix_id_attribute_on_tags assert_html_output( { - '!(#foo)test.png!' => "\n\t\t\t \n\ttest \n\t\t", + '!(#foo)test.png!' => "
", '%(#foo)test%' => "
test
", 'p(#foo). test' => "test
", '|(#foo). test|' => - "\n\t\t
", + "\n\t\t\t \n\ttest \n\t\t\n\t\t
", }, false ) @@ -679,7 +689,7 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase assert_html_output( { '!(wiki-class-foo#wiki-id-bar)test.png!' => - "\n\t\t\t \n\ttest \n\t\t", + "
", }, false ) @@ -711,8 +721,8 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase STR expected = <<~EXPECTED -
<pree>
- This is some text
+<pree>
EXPECTED assert_equal expected.gsub(%r{[\r\n\t]}, ''), to_html(text).gsub(%r{[\r\n\t]}, '') diff --git a/vendor/javascript/tablesort.min.js b/vendor/javascript/tablesort.min.js new file mode 100644 index 000000000..01a6be3e3 --- /dev/null +++ b/vendor/javascript/tablesort.min.js @@ -0,0 +1,6 @@ +/*! + * tablesort v5.7.0 (2026-01-03) + * http://tristen.ca/tablesort/demo/ + * Copyright (c) 2026 ; Licensed MIT + */ +const m=[],v=function(n){if(!window.CustomEvent||typeof window.CustomEvent!="function"){const t=document.createEvent("CustomEvent");return t.initCustomEvent(n,!1,!1,void 0),t}else return new CustomEvent(n)},A=function(n,t){const e=t.sortAttribute||"data-sort";return n.hasAttribute(e)?n.getAttribute(e):n.textContent||n.innerText||""},C=function(n,t){return n=n.trim().toLowerCase(),t=t.trim().toLowerCase(),n===t?0:n
+ This is some text
</pree>0)if(t.tHead&&t.tHead.rows.length>0){for(let s=0;s {this.current&&this.current!==i.target&&this.current.removeAttribute("aria-sort"),this.current=i.target,this.sortTable(i.target)};let s;for(let i=0;i 0&&f.push(r),c++;if(!f)return}for(let o=0;o