diff --git a/app/views/work_package_mailer/_work_package_details.html.erb b/app/views/work_package_mailer/_work_package_details.html.erb index 81b1173dd06..5f53d3e9ef9 100644 --- a/app/views/work_package_mailer/_work_package_details.html.erb +++ b/app/views/work_package_mailer/_work_package_details.html.erb @@ -47,4 +47,4 @@ See COPYRIGHT and LICENSE files for more details. <% end %> -<%= format_text(work_package, :description, only_path: false) %> +<%= format_text(work_package, :description, format: :markdown_as_static_html, only_path: false) %> diff --git a/app/views/work_package_mailer/mentioned.html.erb b/app/views/work_package_mailer/mentioned.html.erb index 69831d69f34..eda3d8e4fb2 100644 --- a/app/views/work_package_mailer/mentioned.html.erb +++ b/app/views/work_package_mailer/mentioned.html.erb @@ -26,7 +26,7 @@ >
- <%= format_text @journal.notes, only_path: false %> + <%= format_text @journal.notes, format: :markdown_as_static_html, only_path: false %>
diff --git a/app/views/work_package_mailer/watcher_changed.html.erb b/app/views/work_package_mailer/watcher_changed.html.erb index 6089c2dd7b9..2275780822e 100644 --- a/app/views/work_package_mailer/watcher_changed.html.erb +++ b/app/views/work_package_mailer/watcher_changed.html.erb @@ -32,6 +32,7 @@ See COPYRIGHT and LICENSE files for more details.

<%= format_text( t(:text_latest_note, note: last_work_package_note(@work_package)), + format: :markdown_as_static_html, only_path: false, object: @work_package, project: @work_package.project diff --git a/lib/open_project/text_formatting/filters/mention_filter.rb b/lib/open_project/text_formatting/filters/mention_filter.rb index 14cc26048e7..6730542395e 100644 --- a/lib/open_project/text_formatting/filters/mention_filter.rb +++ b/lib/open_project/text_formatting/filters/mention_filter.rb @@ -57,8 +57,18 @@ module OpenProject::TextFormatting # and avoids the per-mention query the old `.visible.find_by` did. def preload_work_package_mentions ids = mention_work_package_ids - @mentioned_work_packages = ids.empty? ? {} : WorkPackage.where(id: ids).index_by(&:id) - @visible_mentioned_ids = ids.empty? ? Set.new : WorkPackage.visible.where(id: ids).pluck(:id).to_set + if ids.empty? + @mentioned_work_packages = {} + @visible_mentioned_ids = Set.new + return + end + + scope = WorkPackage.where(id: ids) + # Static-HTML channels need `type` and `status` to render + # quickinfo envelopes as anchors instead of `` widgets. + scope = scope.includes(:type, :status) if context[:as_static_html] + @mentioned_work_packages = scope.index_by(&:id) + @visible_mentioned_ids = WorkPackage.visible.where(id: ids).pluck(:id).to_set end def mention_work_package_ids @@ -109,10 +119,12 @@ module OpenProject::TextFormatting # latter would resolve to a hover-card endpoint the recipient # can't reach. def text_only?(work_package) - context[:plain_text] || @visible_mentioned_ids.exclude?(work_package.id) + context[:as_text] || @visible_mentioned_ids.exclude?(work_package.id) end def work_package_quickinfo(work_package, detailed:) + return work_package_static_macro(work_package, detailed:) if context[:as_static_html] + ApplicationController.helpers.content_tag "opce-macro-wp-quickinfo", "", data: { id: work_package.id, @@ -120,6 +132,21 @@ module OpenProject::TextFormatting detailed: } end + # Static fallback shared with the PatternMatcherFilter's `##`/`###` + # path so envelope-driven and text-driven references render the same + # shape in channels that cannot hydrate the custom element. + def work_package_static_macro(work_package, detailed:) + parts = [] + parts << work_package.status&.name if detailed + parts << work_package.type&.name + parts << work_package.formatted_id + link_text = "#{parts.compact.join(' ')}: #{work_package.subject}" + + link_to(link_text, + work_package_path_or_url(id: work_package.display_id, only_path: context[:only_path]), + class: "issue work_package") + end + def work_package_link(work_package) display_id = work_package.display_id link_to(work_package.formatted_id, diff --git a/lib/open_project/text_formatting/filters/plain_text_output_filter.rb b/lib/open_project/text_formatting/filters/plain_text_output_filter.rb index fb3b0511059..b23c992af0e 100644 --- a/lib/open_project/text_formatting/filters/plain_text_output_filter.rb +++ b/lib/open_project/text_formatting/filters/plain_text_output_filter.rb @@ -32,7 +32,7 @@ module OpenProject::TextFormatting module Filters # Final stage of the plain-text pipeline. Earlier filters resolve # mentions and macros to their text-mode shapes (driven by - # `context[:plain_text]`); this stage collapses any remaining markup + # `context[:as_text]`); this stage collapses any remaining markup # so the pipeline output is suitable for `text/plain` bodies. class PlainTextOutputFilter < HTML::Pipeline::Filter def call diff --git a/lib/open_project/text_formatting/formats/markdown/static_html_formatter.rb b/lib/open_project/text_formatting/formats/markdown/static_html_formatter.rb new file mode 100644 index 00000000000..dcf25ca8b5a --- /dev/null +++ b/lib/open_project/text_formatting/formats/markdown/static_html_formatter.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# 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 COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject::TextFormatting::Formats + module Markdown + # Static-HTML sibling of `Markdown::Formatter`. Shares the same filter + # chain so identifier resolution, mention handling, and link rendering + # stay consistent, but signals `context[:as_static_html]` so matchers + # and filters emit server-rendered anchors in place of JS-hydrated + # custom elements. Intended for channels that cannot run JS — HTML + # mailers, server-side previews, archival exports — where dynamic + # widgets would collapse to empty placeholders. + class StaticHtmlFormatter < Formatter + def initialize(context) + super(context.merge(as_static_html: true)) + end + + def self.format + :markdown_as_static_html + end + end + end +end diff --git a/lib/open_project/text_formatting/formats/markdown/text_formatter.rb b/lib/open_project/text_formatting/formats/markdown/text_formatter.rb index a37d566f233..b320fb967e3 100644 --- a/lib/open_project/text_formatting/formats/markdown/text_formatter.rb +++ b/lib/open_project/text_formatting/formats/markdown/text_formatter.rb @@ -37,7 +37,7 @@ module OpenProject::TextFormatting::Formats # and other channels where HTML would be a foreign body. class TextFormatter < OpenProject::TextFormatting::Formats::BaseFormatter def initialize(context) - super(context.merge(plain_text: true)) + super(context.merge(as_text: true)) end def to_html(text) diff --git a/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb b/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb index 5926c297e41..b1d7f706516 100644 --- a/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb +++ b/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb @@ -104,12 +104,32 @@ module OpenProject::TextFormatting::Matchers label = WorkPackage::SemanticIdentifier.format(display_id) return label if text_only?(work_package) + return render_static_work_package_macro(work_package, label, detailed:) if context[:as_static_html] ApplicationController.helpers.content_tag "opce-macro-wp-quickinfo", "", data: { id:, display_id:, detailed: } end + # Static fallback for channels that cannot hydrate the quickinfo + # custom element (HTML mailers, exports). Mirrors the in-app widget's + # text composition — type, optional status, formatted_id, subject — + # so the anchor reads the same as the rich rendering once flattened. + # Unresolved references collapse to the bare label. + def render_static_work_package_macro(work_package, label, detailed:) + return label unless work_package + + parts = [] + parts << work_package.status&.name if detailed + parts << work_package.type&.name + parts << label + link_text = "#{parts.compact.join(' ')}: #{work_package.subject}" + + link_to(link_text, + work_package_path_or_url(id: work_package.display_id, only_path: context[:only_path]), + class: "issue work_package") + end + def render_work_package_link(work_package, fallback_id:) # Fall back to the bare `#N` shape when no WP is provided (classic mode, # render path bypassing `PatternMatcherFilter`) rather than running a @@ -133,7 +153,7 @@ module OpenProject::TextFormatting::Matchers # WP was preloaded — a nil work_package means a classic-mode render # or an unresolved reference, neither of which needs gating. def text_only?(work_package) - context[:plain_text] || (work_package && !preload_cache.visible?(work_package.id)) + context[:as_text] || (work_package && !preload_cache.visible?(work_package.id)) end def preload_cache diff --git a/lib/open_project/text_formatting/matchers/resource_links_matcher.rb b/lib/open_project/text_formatting/matchers/resource_links_matcher.rb index e5574ea8ef0..c7a420ffa77 100644 --- a/lib/open_project/text_formatting/matchers/resource_links_matcher.rb +++ b/lib/open_project/text_formatting/matchers/resource_links_matcher.rb @@ -163,17 +163,19 @@ module OpenProject::TextFormatting # Doc-level preload called by `PatternMatcherFilter`. Save/restores # the cache so a nested `format_text` (e.g. custom-field formatter - # re-entering the pipeline) doesn't clobber the outer render. Classic - # mode skips the load — `display_id` collapses to numeric, so the - # link handler can render from the matched id alone. - def self.with_preloaded_resources(doc, _context) + # re-entering the pipeline) doesn't clobber the outer render. + # Classic mode normally skips the load (the link handler renders + # `#N` from the matched id alone), but static-HTML channels need + # the WP record in both modes to compose the type/subject/status + # anchor. + def self.with_preloaded_resources(doc, context) previous = RequestStore.store[CACHE_KEY] - return yield unless Setting::WorkPackageIdentifier.semantic? + return yield unless Setting::WorkPackageIdentifier.semantic? || context[:as_static_html] identifiers = collect_work_package_identifiers(doc) return yield if identifiers.empty? - RequestStore.store[CACHE_KEY] = build_cache(identifiers) + RequestStore.store[CACHE_KEY] = build_cache(identifiers, context) yield ensure RequestStore.store[CACHE_KEY] = previous @@ -214,9 +216,20 @@ module OpenProject::TextFormatting # one visibility-scoped id pluck. A third targeted SELECT fires # for historical aliases — the loaded row carries only the current # identifier, so unmapped inputs are filled in from - # `WorkPackageSemanticAlias`. - def self.build_cache(identifiers) - work_packages = WorkPackage.where_display_id_in(*identifiers).select(:id, :identifier).to_a + # `WorkPackageSemanticAlias`. Static-HTML channels also eager-load + # `:type` and `:status` so the link handler can render the + # static-anchor variant of `##`/`###` macros without N+1 queries — + # those associations are the metadata a reader needs to recognise a + # WP reference flattened to text. Anything beyond that (project, + # versions, custom fields) stays out of this preload. + def self.build_cache(identifiers, context = {}) + scope = WorkPackage.where_display_id_in(*identifiers) + scope = if context[:as_static_html] + scope.includes(:type, :status) + else + scope.select(:id, :identifier) + end + work_packages = scope.to_a all_wp_ids = work_packages.map(&:id) visible_ids = WorkPackage.visible.where(id: all_wp_ids).pluck(:id).to_set lookup = index_by_id_and_identifier(work_packages) diff --git a/lib/open_project/text_formatting/renderer.rb b/lib/open_project/text_formatting/renderer.rb index d8f29bfd70e..68b9e6187ad 100644 --- a/lib/open_project/text_formatting/renderer.rb +++ b/lib/open_project/text_formatting/renderer.rb @@ -49,7 +49,7 @@ module OpenProject::TextFormatting .to_html(text) end - # @param [:plain, :markdown_as_text, :rich] format the text format. + # @param [:plain, :markdown_as_text, :markdown_as_static_html, :rich] format the text format. # @return [Formats::BaseFormatter] a formatter implementation. def formatter_for(format) case format.to_sym @@ -57,6 +57,8 @@ module OpenProject::TextFormatting Formats.plain_formatter when :markdown_as_text Formats::Markdown::TextFormatter + when :markdown_as_static_html + Formats::Markdown::StaticHtmlFormatter else Formats.rich_formatter end diff --git a/spec/lib/open_project/text_formatting/formats/markdown/static_html_formatter_spec.rb b/spec/lib/open_project/text_formatting/formats/markdown/static_html_formatter_spec.rb new file mode 100644 index 00000000000..4dc9232ad71 --- /dev/null +++ b/spec/lib/open_project/text_formatting/formats/markdown/static_html_formatter_spec.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# 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 COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe OpenProject::TextFormatting::Formats::Markdown::StaticHtmlFormatter do + subject(:formatted) { described_class.new(context).to_html(input) } + + let(:context) { { only_path: false } } + + shared_let(:project) { create(:project, identifier: "demo") } + shared_let(:type) { create(:type, name: "Task") } + shared_let(:status) { create(:status, name: "New") } + shared_let(:work_package) do + create(:work_package, project:, type:, status:, subject: "Cats V Dogs") + end + shared_let(:admin) { create(:admin) } + + before { login_as(admin) } + + describe "no JS-hydrated custom elements" do + let(:input) { "see #{'##'}#{work_package.id} for details" } + + it "never emits " do + expect(formatted).not_to include("opce-macro-wp-quickinfo") + end + end + + describe "basic mention (#N) — no behaviour change" do + let(:input) { "see ##{work_package.id} please" } + + context "in semantic mode", + with_flag: { semantic_work_package_ids: true }, + with_settings: { work_packages_identifier: "semantic" } do + before { work_package.update_columns(identifier: "DEMO-1", sequence_number: 1) } + + it "renders the formatted_id as an anchor" do + expect(formatted).to include(">DEMO-1<") + expect(formatted).to include('href="') + end + end + end + + describe "quickinfo macro (##N) — type + id + subject in static anchor" do + let(:input) { "see #{'##'}#{work_package.id}" } + + context "in semantic mode", + with_flag: { semantic_work_package_ids: true }, + with_settings: { work_packages_identifier: "semantic" } do + before { work_package.update_columns(identifier: "DEMO-1", sequence_number: 1) } + + it "renders type + formatted_id + subject as a single anchor" do + expect(formatted).to match(%r{]*>Task DEMO-1: Cats V Dogs}) + end + + it "links to the work package show path" do + expect(formatted).to include(%(href="http)) + expect(formatted).to include("/work_packages/DEMO-1") + end + end + + context "in classic mode", + with_flag: { semantic_work_package_ids: false }, + with_settings: { work_packages_identifier: "classic" } do + it "renders type + #N + subject as a single anchor" do + expect(formatted).to match(%r{]*>Task ##{work_package.id}: Cats V Dogs}) + end + end + end + + describe "detailed macro (###N) — status + type + id + subject" do + let(:input) { "see #{'###'}#{work_package.id}" } + + context "in semantic mode", + with_flag: { semantic_work_package_ids: true }, + with_settings: { work_packages_identifier: "semantic" } do + before { work_package.update_columns(identifier: "DEMO-1", sequence_number: 1) } + + it "renders status + type + formatted_id + subject as a single anchor" do + expect(formatted).to match(%r{]*>New Task DEMO-1: Cats V Dogs}) + end + end + + context "in classic mode", + with_flag: { semantic_work_package_ids: false }, + with_settings: { work_packages_identifier: "classic" } do + it "renders status + type + #N + subject as a single anchor" do + expect(formatted).to match(%r{]*>New Task ##{work_package.id}: Cats V Dogs}) + end + end + end + + describe "inaccessible work package" do + shared_let(:other_project) { create(:project, identifier: "secret") } + shared_let(:reader_role) { create(:project_role, permissions: %i[view_work_packages]) } + shared_let(:reader) { create(:user, member_with_roles: { project => reader_role }) } + shared_let(:hidden_wp) do + create(:work_package, project: other_project, type:, status:, subject: "Hidden") + end + + before { login_as(reader) } + + context "in semantic mode", + with_flag: { semantic_work_package_ids: true }, + with_settings: { work_packages_identifier: "semantic" } do + before { hidden_wp.update_columns(identifier: "SECRET-1", sequence_number: 1) } + + it "renders the bare identifier label without an anchor for ##N" do + rendered = described_class.new(context).to_html("see #{'##'}#{hidden_wp.id}") + expect(rendered).to include("SECRET-1") + expect(rendered).not_to match(%r{]*>[^<]*SECRET-1}) + expect(rendered).not_to include("Hidden") + end + + it "renders the bare identifier label without an anchor for ###N" do + rendered = described_class.new(context).to_html("see #{'###'}#{hidden_wp.id}") + expect(rendered).to include("SECRET-1") + expect(rendered).not_to match(%r{]*>[^<]*SECRET-1}) + end + end + end + + describe "work-package mention envelope" do + let(:mention_attrs) do + %(class="mention" data-id="#{work_package.id}" data-type="work_package" data-display-id="DEMO-1") + end + let(:input) { %(check ##DEMO-1) } + + context "in semantic mode", + with_flag: { semantic_work_package_ids: true }, + with_settings: { work_packages_identifier: "semantic" } do + before { work_package.update_columns(identifier: "DEMO-1", sequence_number: 1) } + + it "renders ## quickinfo envelopes as a static anchor with type + id + subject" do + expect(formatted).to match(%r{]*>Task DEMO-1: Cats V Dogs}) + expect(formatted).not_to include("opce-macro-wp-quickinfo") + end + end + end + + describe "format identifier" do + it "exposes :markdown_as_static_html" do + expect(described_class.format).to eq(:markdown_as_static_html) + end + end + + describe "renderer routing" do + it "Renderer.formatter_for(:markdown_as_static_html) resolves to this class" do + expect(OpenProject::TextFormatting::Renderer.formatter_for(:markdown_as_static_html)) + .to eq(described_class) + end + end +end diff --git a/spec/mailers/work_package_mailer_spec.rb b/spec/mailers/work_package_mailer_spec.rb index 64df0a68ab5..017aa2811cb 100644 --- a/spec/mailers/work_package_mailer_spec.rb +++ b/spec/mailers/work_package_mailer_spec.rb @@ -324,5 +324,63 @@ RSpec.describe WorkPackageMailer do expect(body).not_to include(%(href="/work_packages/CHILDPROJ-1")) end end + + describe "rendering a quickinfo/detailed macro in the latest comment" do + shared_let(:persisted_project) { create(:project, identifier: "demo") } + shared_let(:persisted_recipient) { create(:admin) } + shared_let(:macro_type) { create(:type, name: "Task") } + shared_let(:macro_status) { create(:status, name: "New") } + shared_let(:referenced_wp) do + create(:work_package, + project: persisted_project, + type: macro_type, + status: macro_status, + subject: "Cats V Dogs") + end + shared_let(:parent_wp) { create(:work_package, project: persisted_project, subject: "parent") } + + let(:mail) do + create(:work_package_journal, + journable: parent_wp, + user: persisted_recipient, + version: parent_wp.journals.maximum(:version).to_i + 1, + notes: "ref ##{referenced_wp.id} ##{'#'}#{referenced_wp.id} ###{'#'}#{referenced_wp.id}") + described_class.watcher_changed(parent_wp, persisted_recipient, persisted_recipient, "added") + end + + context "with semantic mode", + with_flag: { semantic_work_package_ids: true }, + with_settings: { work_packages_identifier: "semantic" } do + before { referenced_wp.update_columns(identifier: "DEMO-1", sequence_number: 1) } + + it "renders ## quickinfo as a static anchor with type + id + subject" do + body = mail.html_part.body.to_s + expect(body).to match(%r{]*>Task DEMO-1: Cats V Dogs}) + end + + it "renders ### detailed as a static anchor with status + type + id + subject" do + body = mail.html_part.body.to_s + expect(body).to match(%r{]*>New Task DEMO-1: Cats V Dogs}) + end + + it "never leaks into the html body" do + expect(mail.html_part.body.to_s).not_to include("opce-macro-wp-quickinfo") + end + end + + context "with classic mode", + with_flag: { semantic_work_package_ids: false }, + with_settings: { work_packages_identifier: "classic" } do + it "renders ## quickinfo as a static anchor with type + #N + subject" do + body = mail.html_part.body.to_s + expect(body).to match(%r{]*>Task ##{referenced_wp.id}: Cats V Dogs}) + end + + it "renders ### detailed as a static anchor with status + type + #N + subject" do + body = mail.html_part.body.to_s + expect(body).to match(%r{]*>New Task ##{referenced_wp.id}: Cats V Dogs}) + end + end + end end end