Render WP quickinfo macros as static HTML in mailer notes

The `##N` and `###N` work-package macros emit JS-hydrated
`<opce-macro-wp-quickinfo>` custom elements, which mail clients
collapse to empty bullets. Introduce a `:markdown_as_static_html`
format that shares the rich filter chain but signals
`context[:as_static_html]` so the matcher and `MentionFilter` emit a
server-rendered anchor — formatted_id, type name, subject, and (for
`###`) status name — closely mirroring the in-app widget once
flattened.

Mailer HTML templates (`mentioned`, `watcher_changed`,
`_work_package_details`) opt into the new format. Invisible WPs still
render as plain-text labels, matching the cross-project visibility
policy.

`ResourceLinksMatcher.build_cache` and
`MentionFilter#preload_work_package_mentions` eager-load `:type` and
`:status` only when `:as_static_html` is set, leaving the default web
path's two-SELECT shape untouched. Classic-mode preload now also runs
under `:as_static_html` so the link handler can resolve type/subject
for `##`/`###`.

Renames the internal flag `context[:plain_text]` to `context[:as_text]`
to restore symmetry with the user-facing `:markdown_as_text` format.
This commit is contained in:
Kabiru Mwenja
2026-05-26 12:51:00 +03:00
parent faf65203b5
commit ee4c9aee59
12 changed files with 369 additions and 18 deletions
@@ -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 `<opce-*>` 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,
@@ -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
@@ -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
@@ -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)
@@ -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
@@ -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)
+3 -1
View File
@@ -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