Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3cbe554
Use formatted_id and displayId for #N text macros
akabiru Apr 29, 2026
071edb6
Address review feedback on PR 22976
akabiru Apr 29, 2026
5518ccf
Extract PreformattedBlocks; share parse_match; trim WP preload columns
akabiru May 6, 2026
8bddd2d
Accept semantic work-package identifiers in text macros
akabiru May 6, 2026
ba974eb
Pin PDF export macros to numeric-only references
akabiru May 6, 2026
df32664
Use named captures in ResourceLinksMatcher.regexp
akabiru May 6, 2026
1c15bd7
Replace history/jargon framing in code comments
akabiru May 6, 2026
770f96b
Use ActiveRecord::QueryRecorder and shared context in WP macro spec
akabiru May 6, 2026
5977abf
Encapsulate canonical-numeric guard as numeric_id?
akabiru May 7, 2026
a3e4d6b
Reject semantic-shaped ids in HashSeparator#applicable?
akabiru May 8, 2026
f9406ff
Trim verbose contextual comments on WP identifier branch
akabiru May 8, 2026
bc0b59f
Specialise WP link handler with hash_trigger? predicate
akabiru May 8, 2026
480fa51
Cap WP identifier preload IN-list at 500
akabiru May 13, 2026
6053f64
Apply S4 review feedback: drop wps_by_id from fold_in_alias_keys
akabiru May 13, 2026
845c4dc
Splat identifiers Set when calling where_display_id_in
akabiru May 13, 2026
681b9f4
Drop speculative preload cap from text-macro matcher
akabiru May 13, 2026
cbeaeca
Rename process_match arg to satisfy MethodParameterName cop
akabiru May 13, 2026
cfedaff
Spike: drop preload cache, use per-match WP find_by
akabiru May 15, 2026
b7396da
Inline preformatted-blocks skip; drop one-consumer module
akabiru May 15, 2026
c9d3b81
Accept leading-zero ids on HashSeparator for prefixed resource links
akabiru May 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion app/models/work_package/exports/macros/links.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,21 @@
module WorkPackage::Exports
module Macros
class WorkPackagesLinkHandler < OpenProject::TextFormatting::Matchers::LinkHandlers::WorkPackages
# PDF export only handles canonical numeric `#N` references. Semantic and
# leading-zero shapes fall through to literal text to avoid emitting a
# `<mention data-id="0">` (since `"PROJ-1".to_i == 0`). Semantic-id
# support in PDF export is tracked in https://community.openproject.org/wp/74766.
def applicable?
%w(# ## ###).include?(matcher.sep) && matcher.prefix.blank?
hash_trigger? &&
matcher.prefix.blank? &&
WorkPackage::SemanticIdentifier.numeric_id?(matcher.identifier)
end

# PDF rendering walks Markly nodes directly and bypasses the
# `PatternMatcherFilter` preload pipeline, so the parent's cache-driven
# `call` would miss every reference.
def call
render_link(matcher.identifier.to_i, matcher)
end

def render_link(wp_id, matcher)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,11 @@ def self.allowed_prefixes
%w(version message project user group document meeting view)
end

##
# Hash-separated object links
# Condition: Separator is '#'
# Condition: Prefix is present, checked to be one of the allowed values
# Digit-only ids parse via `to_i` into primary keys. Semantic-shaped
# inputs (`version#PROJ-1`) short-circuit here so they don't issue
# `find_by(id: 0)`.
def applicable?
matcher.sep == "#" && valid_prefix? && oid.present?
matcher.sep == "#" && valid_prefix? && matcher.identifier&.match?(/\A\d+\z/)
end

# Examples:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,51 +31,94 @@
module OpenProject::TextFormatting::Matchers
module LinkHandlers
class WorkPackages < Base
##
# Match work package links.
# Condition: Separator is #|##|###
# Condition: Prefix is nil
# CKEditor `#`-based mention triggers for WP references: `#N` plain link,
# `##N` compact quickinfo, `###N` detailed quickinfo. Distinct from the
# matcher's generic `sep` vocabulary (where `#` *separates* prefix from
# id in `version#3`); here it's a sigil that triggers mention recognition.
# Shared with the PDF-export subclass in `app/models/work_package/exports/macros/links.rb`.
HASH_TRIGGERS = %w[# ## ###].freeze

def applicable?
%w(# ## ###).include?(matcher.sep) && matcher.prefix.nil?
hash_trigger? && matcher.prefix.nil?
end

#
# Examples:
#
# #1234, ##1234, ###1234
# Examples: #1234, ##1234, ###1234, #PROJ-7, ##PROJ-7, ###PROJ-7
def call
wp_id = matcher.identifier.to_i
identifier = matcher.identifier

if WorkPackage::SemanticIdentifier.semantic_id?(identifier)
# Semantic shapes are only meaningful in semantic mode; classic
# instances render the literal text fallback.
return nil unless Setting::WorkPackageIdentifier.semantic_mode_active?

render_for_semantic(identifier)
else
# Reject leading-zero shapes like `#0123` that aren't canonical PK strings.
return nil unless WorkPackage::SemanticIdentifier.numeric_id?(identifier)

render_for_numeric(identifier.to_i)
end
end

# Ensure that the element was entered numeric,
# prohibits links to things like #0123
return if wp_id.to_s != matcher.identifier
private

def hash_trigger?
HASH_TRIGGERS.include?(matcher.sep)
end

render_link(wp_id, matcher)
def quickinfo?
matcher.sep.length > 1
end

def render_link(wp_id, matcher)
if ["##", "###"].include?(matcher.sep)
render_work_package_macro(wp_id, detailed: (matcher.sep === "###"))
def detailed?
matcher.sep == "###"
end

def render_for_semantic(display_id)
if quickinfo?
render_work_package_macro(display_id, detailed: detailed?)
else
render_work_package_link(wp_id)
# Plain link needs the WP record for the `formatted_id` label and
# hover-card URL. Unresolved → literal text rather than a broken URL.
wp = WorkPackage.find_by_display_id(display_id)
return nil unless wp

render_work_package_link(wp, fallback_id: display_id)
end
end

private
def render_for_numeric(wp_id)
wp = WorkPackage.find_by_display_id(wp_id)
Comment thread
akabiru marked this conversation as resolved.

def render_work_package_macro(wp_id, detailed: false)
if quickinfo?
# Prefer the resolved WP's display_id so `##1234` typed in semantic
# mode renders the user-facing identifier in the editor model.
data_id = wp&.display_id || wp_id
render_work_package_macro(data_id, detailed: detailed?)
else
render_work_package_link(wp, fallback_id: wp_id)
end
end

def render_work_package_macro(data_id, detailed: false)
ApplicationController.helpers.content_tag "opce-macro-wp-quickinfo",
"",
data: { id: wp_id, detailed: }
data: { id: data_id, detailed: }
end

def render_work_package_link(wp_id)
link_to("##{wp_id}",
work_package_path_or_url(id: wp_id, only_path: context[:only_path]),
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}"
href_id = work_package&.display_id || fallback_id

link_to(label,
work_package_path_or_url(id: href_id, only_path: context[:only_path]),
class: "issue work_package",
data: {
hover_card_trigger_target: "trigger",
hover_card_url: hover_card_work_package_path(wp_id)
hover_card_url: hover_card_work_package_path(href_id)
})
end
end
Expand Down
81 changes: 41 additions & 40 deletions lib/open_project/text_formatting/matchers/resource_links_matcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,14 @@ module Matchers
# OpenProject links matching
#
# Examples:
# Issues:
# #52 -> Link to issue #52
# Work packages:
# #52 -> Plain link to work package 52
# ##52 -> Inline quickinfo card for work package 52
# ###52 -> Detailed quickinfo card for work package 52
# Work packages (semantic identifiers, when the instance is in semantic mode):
# #PROJ-7 -> Plain link to the work package whose display id is PROJ-7
# ##PROJ-7 -> Inline quickinfo card
# ###PROJ-7 -> Detailed quickinfo card
# Changesets:
# r52 -> Link to revision 52
# commit:a85130f -> Link to scmid starting with a85130f
Expand Down Expand Up @@ -76,35 +82,28 @@ class ResourceLinksMatcher < RegexMatcher
include ActionView::Helpers::TextHelper
include ActionView::Helpers::UrlHelper

# Hash and revision separators sit on independent alternation branches
# so semantic ids apply only to `#` references — `r` revisions stay
# numeric-only.
def self.regexp
semantic_id = WorkPackage::SemanticIdentifier::ID_ROUTE_CONSTRAINT.source
prefixes = allowed_prefixes.join("|")
%r{
([[[:space:]](,~\-\[>]|^) # Leading string
(!)? # Escaped marker
(([a-z0-9\-_]+):)? # Project identifier
(#{allowed_prefixes.join('|')})? # prefix
(
(\#+|r)(\d+) # separator and its identifier
(?<leading>[[[:space:]](,~\-\[>]|^)
(?<escaped>!)?
(?<project_prefix>(?<project_identifier>[a-z0-9\-_]+):)?
(?<prefix>#{prefixes})?
(?:
(?<hash_sep>\#+)(?<hash_id>#{semantic_id})
|
(:) # or colon separator
(
[^"\s<>][^\s<>]*? # And a non-quoted value [10]
|
"([^"]+)" # Or a quoted value [11]
)
(?<rev_sep>r)(?<rev_id>\d+)
|
(?<colon_sep>:)(?<colon_value>[^"\s<>][^\s<>]*?|"(?<quoted>[^"]+)")
)
(?=
(?=
[[:punct:]]\W # Includes matches of, e.g., source:foo.ext
)
|\.\z # Allow matching when string ends with .
|, # or with ,
|~ # or with ~
|\) # or with )
|[[:space:]]
|\]
|<
|$
)
(?=[[:punct:]]\W) # Includes matches of, e.g., source:foo.ext
|\.\z|,|~|\)|[[:space:]]|\]|<|$
)
}x
end

Expand All @@ -128,21 +127,23 @@ def self.link_handlers
]
end

def self.process_match(m, matched_string, context)
# Leading string before match
instance = new(
matched_string:,
leading: m[1],
escaped: m[2],
project_prefix: m[3],
project_identifier: m[4],
prefix: m[5],
sep: m[7] || m[9],
raw_identifier: m[8] || m[10],
identifier: m[8] || m[11] || m[10],
context:
)
# Flattens the three alternation branches into a single `:sep` /
# `:identifier` shape so callers don't branch on which one matched.
def self.parse_match(match)
{
leading: match[:leading],
escaped: match[:escaped],
project_prefix: match[:project_prefix],
project_identifier: match[:project_identifier],
prefix: match[:prefix],
sep: match[:hash_sep] || match[:rev_sep] || match[:colon_sep],
raw_identifier: match[:hash_id] || match[:rev_id] || match[:colon_value],
identifier: match[:hash_id] || match[:rev_id] || match[:quoted] || match[:colon_value]
}
end

def self.process_match(matchdata, matched_string, context)
instance = new(matched_string:, context:, **parse_match(matchdata))
instance.process
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,12 @@
it { is_expected.to be_html_eql("<p class='op-uc-p'>#{version_link}</p>") }
end

context "with a leading-zero version id" do
subject { format_text("version#0#{version.id}") }

it { is_expected.to be_html_eql("<p class='op-uc-p'>#{version_link}</p>") }
end

context "Link with version" do
subject { format_text("version:1.0") }

Expand Down
Loading
Loading