mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
Merge branch 'release/17.5' into dev
This commit is contained in:
@@ -43,20 +43,25 @@ module OpenProject
|
||||
|
||||
# @!macro format_text_params
|
||||
# @param [Project] project a Project context.
|
||||
# @param [Boolean] only_path whether to generate links with relative URLs.
|
||||
# @param [Symbol] render_mode the rendering channel (`:in_app_html`,
|
||||
# `:external_html`, `:external_text`). Resolves the `only_path`,
|
||||
# `static_html` and `plain_text` context flags as a set. Prefer this
|
||||
# over passing the primitives individually. See {RenderMode}.
|
||||
# @param [Boolean] only_path explicit override for the resolved `only_path`.
|
||||
# @param [User] current_user the current user context.
|
||||
# @param [:plain, :rich] format the text format.
|
||||
# `:plain` will return plain text.
|
||||
# `:rich` will render raw Markdown as HTML.
|
||||
# @param ** [Hash] additional context to pass to the underlying rendering
|
||||
# pipeline.
|
||||
# pipeline. Explicit `static_html:` / `plain_text:` here override the
|
||||
# values resolved from `render_mode:`.
|
||||
|
||||
# rubocop:disable Layout/LineLength
|
||||
|
||||
##
|
||||
# Formats text according to system settings and provided params.
|
||||
#
|
||||
# @overload format_text(text, object: nil, project: @project || object.try(:project), only_path: true, current_user: User.current, format: :rich, **)
|
||||
# @overload format_text(text, object: nil, project: @project || object.try(:project), render_mode: :in_app_html, only_path: nil, current_user: User.current, format: :rich, **)
|
||||
#
|
||||
# @param [String] text the raw text to be formatted, typically Markdown.
|
||||
# @param [Object] object an object context.
|
||||
@@ -64,10 +69,10 @@ module OpenProject
|
||||
#
|
||||
# @example Setting a project context explicitly
|
||||
# format_text("## Hello world", project: current_project)
|
||||
# @example Generating links with full URLs
|
||||
# format_text("[Projects](/projects)", only_path: false)
|
||||
# @example Rendering for an external surface (mailer, RSS, export)
|
||||
# format_text("see #42", render_mode: :external_html)
|
||||
#
|
||||
# @overload format_text(object, attribute, project: @project || object.try(:project), only_path: true, current_user: User.current, format: :rich, **)
|
||||
# @overload format_text(object, attribute, project: @project || object.try(:project), render_mode: :in_app_html, only_path: nil, current_user: User.current, format: :rich, **)
|
||||
#
|
||||
# @param [Object] object an object, typically a model
|
||||
# (i.e. `ActiveRecord::Base` descendent).
|
||||
@@ -79,7 +84,8 @@ module OpenProject
|
||||
# format_text(issue, :description, options)
|
||||
#
|
||||
# @return [String] the formatted text as an HTML-safe String.
|
||||
def format_text(*args, object: nil, project: nil, only_path: true, current_user: User.current, format: :rich, **)
|
||||
def format_text(*args, object: nil, project: nil, render_mode: :in_app_html,
|
||||
only_path: nil, current_user: User.current, format: :rich, **kwargs)
|
||||
case args.size
|
||||
when 1
|
||||
attribute = nil
|
||||
@@ -94,15 +100,22 @@ module OpenProject
|
||||
|
||||
project ||= @project || object.try(:project)
|
||||
|
||||
resolved = RenderMode.resolve(
|
||||
render_mode,
|
||||
only_path:,
|
||||
static_html: kwargs.delete(:static_html),
|
||||
plain_text: kwargs.delete(:plain_text)
|
||||
)
|
||||
|
||||
Renderer.format_text(
|
||||
text,
|
||||
**,
|
||||
**kwargs,
|
||||
format:,
|
||||
object:,
|
||||
request: try(:request),
|
||||
current_user:,
|
||||
attribute:,
|
||||
only_path:,
|
||||
**resolved,
|
||||
project:
|
||||
)
|
||||
end
|
||||
|
||||
@@ -37,6 +37,8 @@ module OpenProject::TextFormatting
|
||||
include OpenProject::StaticRouting::UrlHelpers
|
||||
|
||||
def call
|
||||
preload_mentions
|
||||
|
||||
doc.search("mention").each do |mention|
|
||||
anchor = mention_anchor(mention)
|
||||
mention.replace(anchor) if anchor
|
||||
@@ -47,6 +49,41 @@ module OpenProject::TextFormatting
|
||||
|
||||
private
|
||||
|
||||
# WP labels resolve regardless of viewer (so an inaccessible WP
|
||||
# still renders its current formatted_id); a separate id pluck
|
||||
# gates anchor-vs-text. Principals collapse the two concerns into
|
||||
# one visibility-scoped fetch — invisible users and groups fall
|
||||
# back to the literal envelope text.
|
||||
def preload_mentions
|
||||
preload_work_package_mentions
|
||||
preload_principal_mentions
|
||||
end
|
||||
|
||||
def preload_work_package_mentions
|
||||
ids = mention_ids_for("work_package")
|
||||
if ids.empty?
|
||||
@mentioned_work_packages = {}
|
||||
@visible_mentioned_ids = Set.new
|
||||
return
|
||||
end
|
||||
|
||||
scope = WorkPackage.where(id: ids)
|
||||
scope = scope.includes(:type, :status) if context[:static_html]
|
||||
@mentioned_work_packages = scope.index_by(&:id)
|
||||
@visible_mentioned_ids = WorkPackage.visible.where(id: ids).pluck(:id).to_set
|
||||
end
|
||||
|
||||
def preload_principal_mentions
|
||||
user_ids = mention_ids_for("user")
|
||||
group_ids = mention_ids_for("group")
|
||||
@mentioned_users = user_ids.empty? ? {} : User.visible.where(id: user_ids).index_by(&:id)
|
||||
@mentioned_groups = group_ids.empty? ? {} : Group.visible.where(id: group_ids).index_by(&:id)
|
||||
end
|
||||
|
||||
def mention_ids_for(type)
|
||||
doc.css(%(mention[data-type="#{type}"])).filter_map { mention_id(it)&.to_i }.uniq
|
||||
end
|
||||
|
||||
def mention_anchor(mention)
|
||||
mention_instance = class_from_mention(mention)
|
||||
|
||||
@@ -75,45 +112,61 @@ module OpenProject::TextFormatting
|
||||
end
|
||||
|
||||
def work_package_mention(work_package, mention)
|
||||
# Render the mention with the same label and URL convention used for
|
||||
# `#N` text references elsewhere in the markdown pipeline.
|
||||
display_id = work_package.display_id
|
||||
return Nokogiri::XML::Text.new(work_package.formatted_id, mention.document) if text_only?(work_package)
|
||||
|
||||
case mention.text.count("#")
|
||||
when 3
|
||||
ApplicationController.helpers.content_tag "opce-macro-wp-quickinfo",
|
||||
"",
|
||||
data: { id: work_package.id, display_id:, detailed: true }
|
||||
when 2
|
||||
ApplicationController.helpers.content_tag "opce-macro-wp-quickinfo",
|
||||
"",
|
||||
data: { id: work_package.id, display_id:, detailed: false }
|
||||
else
|
||||
link_to(work_package.formatted_id,
|
||||
work_package_path_or_url(id: display_id, only_path: context[:only_path]),
|
||||
class: "issue work_package",
|
||||
data: {
|
||||
hover_card_trigger_target: "trigger",
|
||||
hover_card_url: hover_card_work_package_path(display_id)
|
||||
})
|
||||
when 3 then work_package_quickinfo(work_package, detailed: true)
|
||||
when 2 then work_package_quickinfo(work_package, detailed: false)
|
||||
else work_package_link(work_package)
|
||||
end
|
||||
end
|
||||
|
||||
def class_from_mention(mention)
|
||||
mention_class = case mention.attributes["data-type"].value
|
||||
when "user"
|
||||
User
|
||||
when "group"
|
||||
Group
|
||||
when "work_package"
|
||||
WorkPackage
|
||||
else
|
||||
raise ArgumentError
|
||||
end
|
||||
# The hover-card endpoint a quickinfo would link to is unreachable
|
||||
# for plain-text recipients and for viewers without view permission.
|
||||
def text_only?(work_package)
|
||||
context[:plain_text] || @visible_mentioned_ids.exclude?(work_package.id)
|
||||
end
|
||||
|
||||
mention_class
|
||||
.visible
|
||||
.find_by(id: mention_id(mention)) || fallback_text(mention)
|
||||
def work_package_quickinfo(work_package, detailed:)
|
||||
return work_package_static_macro(work_package, detailed:) if context[:static_html]
|
||||
|
||||
ApplicationController.helpers.content_tag "opce-macro-wp-quickinfo",
|
||||
"",
|
||||
data: { id: work_package.id,
|
||||
display_id: work_package.display_id,
|
||||
detailed: }
|
||||
end
|
||||
|
||||
# Uses the WP's current `formatted_id` rather than the envelope text,
|
||||
# so a renamed identifier doesn't leave a stale label in the mailer.
|
||||
def work_package_static_macro(work_package, detailed:)
|
||||
label = OpenProject::TextFormatting::Helpers::StaticMacroLabel
|
||||
.call(work_package, label: work_package.formatted_id, detailed:)
|
||||
|
||||
link_to(label,
|
||||
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,
|
||||
work_package_path_or_url(id: display_id, only_path: context[:only_path]),
|
||||
class: "issue work_package",
|
||||
data: {
|
||||
hover_card_trigger_target: "trigger",
|
||||
hover_card_url: hover_card_work_package_path(display_id)
|
||||
})
|
||||
end
|
||||
|
||||
def class_from_mention(mention)
|
||||
id = mention_id(mention)&.to_i
|
||||
case mention.attributes["data-type"].value
|
||||
when "user" then @mentioned_users[id]
|
||||
when "group" then @mentioned_groups[id]
|
||||
when "work_package" then @mentioned_work_packages[id]
|
||||
else raise ArgumentError
|
||||
end || fallback_text(mention)
|
||||
end
|
||||
|
||||
##
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# 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
|
||||
module Filters
|
||||
# Terminal stage when the rich pipeline is rendering for `text/plain`
|
||||
# bodies — collapses the DOM to its visible text so no HTML escapes.
|
||||
class PlainTextOutputFilter < HTML::Pipeline::Filter
|
||||
def call
|
||||
doc.text
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -31,11 +31,45 @@ require "task_list/filter"
|
||||
|
||||
module OpenProject::TextFormatting::Formats::Markdown
|
||||
class Formatter < OpenProject::TextFormatting::Formats::BaseFormatter
|
||||
RICH_FILTERS = [
|
||||
OpenProject::TextFormatting::Filters::SettingMacrosFilter,
|
||||
OpenProject::TextFormatting::Filters::MarkdownFilter,
|
||||
OpenProject::TextFormatting::Filters::SanitizationFilter,
|
||||
OpenProject::TextFormatting::Filters::TaskListFilter,
|
||||
OpenProject::TextFormatting::Filters::TableOfContentsFilter,
|
||||
OpenProject::TextFormatting::Filters::MacroFilter,
|
||||
OpenProject::TextFormatting::Filters::MentionFilter,
|
||||
OpenProject::TextFormatting::Filters::PatternMatcherFilter,
|
||||
OpenProject::TextFormatting::Filters::SyntaxHighlightFilter,
|
||||
OpenProject::TextFormatting::Filters::AttachmentFilter,
|
||||
OpenProject::TextFormatting::Filters::AutolinkFilter,
|
||||
OpenProject::TextFormatting::Filters::AutolinkCustomProtocolsFilter,
|
||||
OpenProject::TextFormatting::Filters::RelativeLinkFilter,
|
||||
OpenProject::TextFormatting::Filters::LinkAttributeFilter,
|
||||
OpenProject::TextFormatting::Filters::ExternalLinkCaptureFilter,
|
||||
OpenProject::TextFormatting::Filters::FigureWrappedFilter,
|
||||
OpenProject::TextFormatting::Filters::BemCssFilter
|
||||
].freeze
|
||||
|
||||
# `text/plain` mailer bodies share the matcher and mention stages so
|
||||
# work-package references resolve consistently with the HTML channel,
|
||||
# then `PlainTextOutputFilter` collapses the DOM to text. Filters that
|
||||
# only shape HTML (TOC, syntax highlight, autolink, link-attribute,
|
||||
# figure, BEM) are omitted because `doc.text` would discard their work.
|
||||
TEXT_FILTERS = [
|
||||
OpenProject::TextFormatting::Filters::SettingMacrosFilter,
|
||||
OpenProject::TextFormatting::Filters::MarkdownFilter,
|
||||
OpenProject::TextFormatting::Filters::SanitizationFilter,
|
||||
OpenProject::TextFormatting::Filters::MentionFilter,
|
||||
OpenProject::TextFormatting::Filters::PatternMatcherFilter,
|
||||
OpenProject::TextFormatting::Filters::PlainTextOutputFilter
|
||||
].freeze
|
||||
|
||||
def to_html(text)
|
||||
result = pipeline.call(text, context)
|
||||
output = result[:output].to_s
|
||||
|
||||
output.html_safe
|
||||
context[:plain_text] ? output : output.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
|
||||
def to_document(text)
|
||||
@@ -43,25 +77,7 @@ module OpenProject::TextFormatting::Formats::Markdown
|
||||
end
|
||||
|
||||
def filters
|
||||
[
|
||||
OpenProject::TextFormatting::Filters::SettingMacrosFilter,
|
||||
OpenProject::TextFormatting::Filters::MarkdownFilter,
|
||||
OpenProject::TextFormatting::Filters::SanitizationFilter,
|
||||
OpenProject::TextFormatting::Filters::TaskListFilter,
|
||||
OpenProject::TextFormatting::Filters::TableOfContentsFilter,
|
||||
OpenProject::TextFormatting::Filters::MacroFilter,
|
||||
OpenProject::TextFormatting::Filters::MentionFilter,
|
||||
OpenProject::TextFormatting::Filters::PatternMatcherFilter,
|
||||
OpenProject::TextFormatting::Filters::SyntaxHighlightFilter,
|
||||
OpenProject::TextFormatting::Filters::AttachmentFilter,
|
||||
OpenProject::TextFormatting::Filters::AutolinkFilter,
|
||||
OpenProject::TextFormatting::Filters::AutolinkCustomProtocolsFilter,
|
||||
OpenProject::TextFormatting::Filters::RelativeLinkFilter,
|
||||
OpenProject::TextFormatting::Filters::LinkAttributeFilter,
|
||||
OpenProject::TextFormatting::Filters::ExternalLinkCaptureFilter,
|
||||
OpenProject::TextFormatting::Filters::FigureWrappedFilter,
|
||||
OpenProject::TextFormatting::Filters::BemCssFilter
|
||||
]
|
||||
context[:plain_text] ? TEXT_FILTERS : RICH_FILTERS
|
||||
end
|
||||
|
||||
def self.format
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# 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
|
||||
module Helpers
|
||||
# Anchor text for the static-HTML form of a WP quickinfo macro:
|
||||
# `[status ]type label: subject`. Used in channels (HTML mailers,
|
||||
# server-side previews) that can't hydrate the `<opce-*>` widget.
|
||||
module StaticMacroLabel
|
||||
def self.call(work_package, label:, detailed:)
|
||||
parts = []
|
||||
parts << work_package.status&.name if detailed
|
||||
parts << work_package.type&.name
|
||||
parts << label
|
||||
"#{parts.compact.join(' ')}: #{work_package.subject}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -78,41 +78,56 @@ module OpenProject::TextFormatting::Matchers
|
||||
# Both quickinfo and plain link need the WP record so the rendered
|
||||
# HTML can carry the record id in `data-id`. Unresolved WP →
|
||||
# literal text rather than a broken reference.
|
||||
wp = OpenProject::TextFormatting::Matchers::ResourceLinksMatcher.work_package_for(display_id)
|
||||
wp = preload_cache.fetch(display_id)
|
||||
return nil unless wp
|
||||
|
||||
if quickinfo?
|
||||
render_work_package_macro(id: wp.id, display_id: wp.display_id, detailed: detailed?)
|
||||
render_work_package_macro(work_package: wp, fallback_id: display_id, detailed: detailed?)
|
||||
else
|
||||
render_work_package_link(wp, fallback_id: display_id)
|
||||
end
|
||||
end
|
||||
|
||||
def render_for_numeric(wp_id)
|
||||
wp = OpenProject::TextFormatting::Matchers::ResourceLinksMatcher.work_package_for(wp_id)
|
||||
wp = preload_cache.fetch(wp_id)
|
||||
|
||||
if quickinfo?
|
||||
# Prefer the resolved WP's identifiers; fall back to the matched
|
||||
# id when no preload is available (classic mode).
|
||||
record_id = wp&.id || wp_id
|
||||
display_id = wp&.display_id || wp_id
|
||||
render_work_package_macro(id: record_id, display_id:, detailed: detailed?)
|
||||
render_work_package_macro(work_package: wp, fallback_id: wp_id, detailed: detailed?)
|
||||
else
|
||||
render_work_package_link(wp, fallback_id: wp_id)
|
||||
end
|
||||
end
|
||||
|
||||
def render_work_package_macro(id:, display_id:, detailed: false)
|
||||
def render_work_package_macro(work_package:, fallback_id:, detailed: false)
|
||||
id = work_package&.id || fallback_id
|
||||
display_id = work_package&.display_id || fallback_id
|
||||
label = WorkPackage::SemanticIdentifier.format_display_id(display_id)
|
||||
|
||||
return label if text_only?(work_package)
|
||||
return render_static_work_package_macro(work_package, label, detailed:) if context[:static_html]
|
||||
|
||||
ApplicationController.helpers.content_tag "opce-macro-wp-quickinfo",
|
||||
"",
|
||||
data: { id:, display_id:, detailed: }
|
||||
end
|
||||
|
||||
# The label keeps what the author wrote (possibly a historical
|
||||
# alias) so the rendered text matches the source markdown.
|
||||
def render_static_work_package_macro(work_package, label, detailed:)
|
||||
return label unless work_package
|
||||
|
||||
link_to(OpenProject::TextFormatting::Helpers::StaticMacroLabel.call(work_package, label:, detailed:),
|
||||
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
|
||||
# per-link query inside the renderer.
|
||||
label = work_package&.formatted_id || "##{fallback_id}"
|
||||
return label if text_only?(work_package)
|
||||
|
||||
href_id = work_package&.display_id || fallback_id
|
||||
|
||||
link_to(label,
|
||||
@@ -123,6 +138,16 @@ module OpenProject::TextFormatting::Matchers
|
||||
hover_card_url: hover_card_work_package_path(href_id)
|
||||
})
|
||||
end
|
||||
|
||||
# A nil WP means classic mode skipped the preload, or the reference
|
||||
# didn't resolve — neither case needs visibility gating.
|
||||
def text_only?(work_package)
|
||||
context[:plain_text] || (work_package && !preload_cache.visible?(work_package.id))
|
||||
end
|
||||
|
||||
def preload_cache
|
||||
OpenProject::TextFormatting::Matchers::ResourceLinksMatcher.current_cache
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -69,8 +69,31 @@ module OpenProject::TextFormatting
|
||||
# identifier:version:1.0.0
|
||||
# identifier:source:some/file
|
||||
class ResourceLinksMatcher < RegexMatcher
|
||||
WORK_PACKAGES_LOOKUP_KEY = :text_formatting_work_packages_lookup
|
||||
private_constant :WORK_PACKAGES_LOOKUP_KEY
|
||||
# Unscoped label resolution (`lookup`) paired with viewer-scoped
|
||||
# link gating (`visible_ids`), so the link handler renders the
|
||||
# same label for everyone and decides anchor-vs-text per viewer.
|
||||
class WorkPackagePreloadCache
|
||||
attr_reader :lookup, :visible_ids
|
||||
|
||||
def initialize(lookup:, visible_ids:)
|
||||
@lookup = lookup
|
||||
@visible_ids = visible_ids
|
||||
end
|
||||
|
||||
def fetch(identifier)
|
||||
lookup[identifier.to_s]
|
||||
end
|
||||
|
||||
def visible?(work_package_id)
|
||||
visible_ids.include?(work_package_id)
|
||||
end
|
||||
|
||||
# Frozen singleton, not a factory — callers must not mutate it.
|
||||
EMPTY = new(lookup: {}.freeze, visible_ids: Set.new.freeze).freeze
|
||||
end
|
||||
|
||||
CACHE_KEY = :text_formatting_work_package_preload_cache
|
||||
private_constant :CACHE_KEY
|
||||
|
||||
include ::OpenProject::TextFormatting::Truncation
|
||||
# used for the work package quick links
|
||||
@@ -130,33 +153,33 @@ module OpenProject::TextFormatting
|
||||
]
|
||||
end
|
||||
|
||||
# Returns the preloaded WorkPackage for the given identifier (numeric
|
||||
# or semantic), or nil if no preload is active (classic mode, no `#N`
|
||||
# references) or the WP couldn't be resolved. Lookup keys are always
|
||||
# strings — see `index_by_id_and_identifier`.
|
||||
def self.work_package_for(identifier)
|
||||
RequestStore.store.dig(WORK_PACKAGES_LOOKUP_KEY, identifier.to_s)
|
||||
def self.current_cache
|
||||
RequestStore.store[CACHE_KEY] || WorkPackagePreloadCache::EMPTY
|
||||
end
|
||||
|
||||
# Doc-level preload called by `PatternMatcherFilter`. Save/restores
|
||||
# the lookup 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)
|
||||
previous = RequestStore.store[WORK_PACKAGES_LOOKUP_KEY]
|
||||
|
||||
return yield unless Setting::WorkPackageIdentifier.semantic?
|
||||
# Save/restore so a nested `format_text` (e.g. a custom-field
|
||||
# formatter re-entering the pipeline) doesn't clobber the outer
|
||||
# render's cache.
|
||||
def self.with_preloaded_resources(doc, context)
|
||||
previous = RequestStore.store[CACHE_KEY]
|
||||
return yield unless preload_required?(context)
|
||||
|
||||
identifiers = collect_work_package_identifiers(doc)
|
||||
return yield if identifiers.empty?
|
||||
|
||||
RequestStore.store[WORK_PACKAGES_LOOKUP_KEY] = build_lookup(identifiers)
|
||||
RequestStore.store[CACHE_KEY] = build_cache(identifiers, context)
|
||||
yield
|
||||
ensure
|
||||
RequestStore.store[WORK_PACKAGES_LOOKUP_KEY] = previous
|
||||
RequestStore.store[CACHE_KEY] = previous
|
||||
end
|
||||
|
||||
# Semantic mode needs the row to map `PROJ-7` to an id; static-HTML
|
||||
# output needs `type`/`subject` to compose the quickinfo anchor.
|
||||
def self.preload_required?(context)
|
||||
Setting::WorkPackageIdentifier.semantic? || context[:static_html]
|
||||
end
|
||||
private_class_method :preload_required?
|
||||
|
||||
def self.collect_work_package_identifiers(doc)
|
||||
identifiers = Set.new
|
||||
doc.search(".//text()").each do |node|
|
||||
@@ -182,19 +205,25 @@ module OpenProject::TextFormatting
|
||||
identifier
|
||||
end
|
||||
|
||||
# 1 SELECT in the common case. A second targeted SELECT fires for
|
||||
# historical aliases — the loaded WP row carries only its current
|
||||
# identifier, so unmapped inputs must be filled in from
|
||||
# `WorkPackageSemanticAlias`. The visible-id set passes through
|
||||
# to the alias fold-in explicitly so each query enforces
|
||||
# visibility at its own boundary, leaving no implicit trust for
|
||||
# the cache to leak through.
|
||||
def self.build_lookup(identifiers)
|
||||
work_packages = WorkPackage.visible.where_display_id_in(*identifiers).select(:id, :identifier).to_a
|
||||
# Two SELECTs in the common case (unscoped fetch + visibility id
|
||||
# pluck), a third when historical aliases need resolving. Static-
|
||||
# HTML output additionally needs `:type` and `:status` to compose
|
||||
# the anchor for `##`/`###` macros.
|
||||
def self.build_cache(identifiers, context = {})
|
||||
scope = WorkPackage.where_display_id_in(*identifiers)
|
||||
scope = if context[: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)
|
||||
fold_in_alias_keys(lookup, identifiers, visible_wp_ids: work_packages.map(&:id))
|
||||
lookup
|
||||
fold_in_alias_keys(lookup, identifiers, all_wp_ids:)
|
||||
WorkPackagePreloadCache.new(lookup:, visible_ids:)
|
||||
end
|
||||
private_class_method :build_cache
|
||||
|
||||
# Keys are stringified at write time so callers can read with a single
|
||||
# `identifier.to_s` regardless of whether the input is a numeric id or
|
||||
@@ -207,12 +236,12 @@ module OpenProject::TextFormatting
|
||||
end
|
||||
private_class_method :index_by_id_and_identifier
|
||||
|
||||
def self.fold_in_alias_keys(lookup, identifiers, visible_wp_ids:)
|
||||
def self.fold_in_alias_keys(lookup, identifiers, all_wp_ids:)
|
||||
unmapped = identifiers.map(&:to_s) - lookup.keys
|
||||
return if unmapped.empty? || visible_wp_ids.empty?
|
||||
return if unmapped.empty? || all_wp_ids.empty?
|
||||
|
||||
WorkPackageSemanticAlias
|
||||
.where(work_package_id: visible_wp_ids, identifier: unmapped)
|
||||
.where(work_package_id: all_wp_ids, identifier: unmapped)
|
||||
.pluck(:identifier, :work_package_id)
|
||||
.each { |ident, wp_id| lookup[ident] = lookup[wp_id.to_s] }
|
||||
end
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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
|
||||
module TextFormatting
|
||||
# Maps a high-level rendering channel (`:in_app_html`, `:external_html`,
|
||||
# `:external_text`) onto the primitive `only_path` / `static_html` /
|
||||
# `plain_text` context flags that the filter pipeline reads.
|
||||
#
|
||||
# External surfaces always need absolute URLs *and* static rendering for
|
||||
# JS-dependent components — the two flags are a coupled set. A single
|
||||
# mode value is the canonical API; the primitives stay available as
|
||||
# per-flag escape hatches for callers that need an asymmetric mix.
|
||||
module RenderMode
|
||||
DEFAULTS = {
|
||||
in_app_html: { only_path: true, static_html: false, plain_text: false }.freeze,
|
||||
external_html: { only_path: false, static_html: true, plain_text: false }.freeze,
|
||||
external_text: { only_path: false, static_html: false, plain_text: true }.freeze
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def resolve(mode, only_path: nil, static_html: nil, plain_text: nil)
|
||||
defaults = DEFAULTS.fetch(mode) do
|
||||
raise ArgumentError, "Unknown render_mode: #{mode.inspect}. " \
|
||||
"Expected one of #{DEFAULTS.keys.inspect}."
|
||||
end
|
||||
|
||||
{
|
||||
only_path: only_path.nil? ? defaults[:only_path] : only_path,
|
||||
static_html: static_html.nil? ? defaults[:static_html] : static_html,
|
||||
plain_text: plain_text.nil? ? defaults[:plain_text] : plain_text
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user